step 2: delete dropped packages and handlers

Removed 16 packages: tools, compaction, extraction, roles, mentions,
notelinks, export, memory, knowledge, providers, routing, capabilities,
filters, retention, workspace, treepath

Removed 45 handler files (chat, AI, tools, personas, providers,
model routing, usage tracking, summarization, title gen, streaming)

127 files deleted, ~37K lines removed.
Build will break — expected, fixed in steps 3-5.
This commit is contained in:
2026-03-25 19:52:08 -04:00
parent 11fd8c1e57
commit be6f9519f8
127 changed files with 0 additions and 37016 deletions

View File

@@ -1,299 +0,0 @@
package capabilities
import (
"testing"
"switchboard-core/models"
)
func TestLookupKnownModel_AlwaysFalse(t *testing.T) {
// Known table removed in 0.9.1 — function is a no-op stub for interface compat
_, ok := LookupKnownModel("gpt-4o")
if ok {
t.Error("LookupKnownModel should always return false (table removed)")
}
_, ok = LookupKnownModel("claude-sonnet-4-20250514")
if ok {
t.Error("LookupKnownModel should always return false (table removed)")
}
}
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},
{"qwen3-235b-a22b-thinking", true},
{"phi-3-mini", true},
{"gpt-4o", true},
{"claude-sonnet-4-20250514", true},
{"gemini-2.5-pro", true},
{"grok-41-fast", true},
{"kimi-k2-thinking", true},
{"minimax-m2.5", 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) {
tests := []struct {
model string
want bool
}{
{"llava-v1.6-34b", true},
{"gemini-2.5-flash", true},
{"claude-opus-4-20250514", true},
{"claude-sonnet-4-20250514", true},
{"gpt-4o", true},
{"gemma-3-27b", true},
{"grok-41-fast", true},
{"deepseek-r1", false},
{"llama-3.1-70b", false},
}
for _, tt := range tests {
caps := InferCapabilities(tt.model)
if caps.Vision != tt.want {
t.Errorf("InferCapabilities(%q).Vision = %v, want %v", tt.model, caps.Vision, tt.want)
}
}
}
func TestInferCapabilities_Reasoning(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"deepseek-r1-distill-llama-70b", true},
{"qwq-32b", true},
{"o3-mini", true},
{"qwen3-235b-a22b-thinking-2507", true},
{"grok-41-fast", true},
{"glm-5-32b", true},
{"gpt-4o", false},
{"llama-3.1-70b", false},
}
for _, tt := range tests {
caps := InferCapabilities(tt.model)
if caps.Reasoning != tt.want {
t.Errorf("InferCapabilities(%q).Reasoning = %v, want %v", tt.model, caps.Reasoning, tt.want)
}
}
}
func TestInferCapabilities_CodeOptimized(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"codestral", true},
{"deepseek-coder-33b", true},
{"qwen3-coder-480b", true},
{"starcoder2-15b", true},
{"gpt-4o", false},
{"llama-3.1-70b", false},
}
for _, tt := range tests {
caps := InferCapabilities(tt.model)
if caps.CodeOptimized != tt.want {
t.Errorf("InferCapabilities(%q).CodeOptimized = %v, want %v", tt.model, caps.CodeOptimized, tt.want)
}
}
}
func TestResolveMaxOutput_FromCaps(t *testing.T) {
caps := models.ModelCapabilities{MaxOutputTokens: 32000}
got := ResolveMaxOutput("whatever-model", caps)
if got != 32000 {
t.Errorf("got %d, want 32000", got)
}
}
func TestResolveMaxOutput_FromContext(t *testing.T) {
caps := models.ModelCapabilities{MaxContext: 32768}
got := ResolveMaxOutput("unknown-model-abc", caps)
if got != 4096 {
t.Errorf("got %d, want 4096 (derived from context/8)", got)
}
}
func TestResolveMaxOutput_ContextClamp(t *testing.T) {
caps := models.ModelCapabilities{MaxContext: 1048576}
got := ResolveMaxOutput("unknown-large-model", caps)
if got != 16384 {
t.Errorf("got %d, want 16384 (clamped)", got)
}
caps = models.ModelCapabilities{MaxContext: 4096}
got = ResolveMaxOutput("unknown-tiny-model", caps)
if got != 2048 {
t.Errorf("got %d, want 2048 (clamped)", got)
}
}
func TestResolveMaxOutput_LastResort(t *testing.T) {
caps := models.ModelCapabilities{}
got := ResolveMaxOutput("mystery-model", caps)
if got != 4096 {
t.Errorf("got %d, want 4096 (last resort)", got)
}
}
func TestResolveIntrinsic_CatalogWins(t *testing.T) {
// Provider reports tool_calling and max_output — authoritative.
// Heuristic should fill vision for claude (regex match).
providerCaps := models.ModelCapabilities{
ToolCalling: true,
MaxOutputTokens: 16384,
}
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps, nil)
if !merged.ToolCalling {
t.Error("tool_calling should be preserved from provider")
}
if merged.MaxOutputTokens != 16384 {
t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens)
}
if !merged.Vision {
t.Error("vision should be filled from heuristic (claude-sonnet matches)")
}
}
func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) {
// Provider says no vision — heuristic shouldn't override.
// mergeGaps only fills false→true, never overrides true→false.
// But: provider set vision=false explicitly. Since mergeGaps uses
// "fill zero", and false IS zero, heuristic CAN fill it.
// This is the expected behavior: heuristic is additive best-effort.
providerCaps := models.ModelCapabilities{
ToolCalling: true,
Vision: false,
MaxOutputTokens: 8192,
MaxContext: 65536,
}
merged := ResolveIntrinsic("some-unknown-model", &providerCaps, nil)
if !merged.ToolCalling {
t.Error("tool_calling should be preserved from provider")
}
if merged.MaxContext != 65536 {
t.Errorf("max_context should be preserved from provider, got %d", merged.MaxContext)
}
}
func TestResolveIntrinsic_NilProvider(t *testing.T) {
// Nil provider caps — falls through entirely to heuristic
merged := ResolveIntrinsic("gpt-4o", nil, nil)
if !merged.ToolCalling {
t.Error("should get tool_calling from heuristic (gpt-4 pattern)")
}
if !merged.Vision {
t.Error("should get vision from heuristic (gpt-4o pattern)")
}
}
func TestResolveIntrinsic_HeuristicOnly(t *testing.T) {
// No catalog, no known table — pure heuristic
merged := ResolveIntrinsic("deepseek-r1-distill-llama-70b", nil, nil)
if !merged.Reasoning {
t.Error("should infer reasoning from deepseek-r1 pattern")
}
if !merged.Streaming {
t.Error("should infer streaming (everything streams)")
}
}
func TestHasProviderData(t *testing.T) {
empty := models.ModelCapabilities{}
if empty.HasProviderData() {
t.Error("empty caps should not have provider data")
}
withTool := models.ModelCapabilities{ToolCalling: true}
if !withTool.HasProviderData() {
t.Error("caps with tool_calling should have provider data")
}
withContext := models.ModelCapabilities{MaxContext: 128000}
if !withContext.HasProviderData() {
t.Error("caps with max_context should have provider data")
}
}
func TestResolveIntrinsic_AdminOverrides(t *testing.T) {
// Catalog says no vision, heuristic says no vision,
// but admin override forces vision=true.
catalogCaps := &models.ModelCapabilities{
ToolCalling: true,
Vision: false,
}
overrides := []models.CapabilityOverride{
{Field: "vision", Value: "true"},
}
merged := ResolveIntrinsic("some-custom-model", catalogCaps, overrides)
if !merged.Vision {
t.Error("admin override should force vision=true")
}
if !merged.ToolCalling {
t.Error("catalog tool_calling should be preserved")
}
}
func TestResolveIntrinsic_AdminOverrides_DisableCapability(t *testing.T) {
// Heuristic infers tool_calling for gpt-4o, but admin says no.
overrides := []models.CapabilityOverride{
{Field: "tool_calling", Value: "false"},
}
merged := ResolveIntrinsic("gpt-4o", nil, overrides)
if merged.ToolCalling {
t.Error("admin override should disable tool_calling")
}
}
func TestResolveIntrinsic_AdminOverrides_IntValues(t *testing.T) {
overrides := []models.CapabilityOverride{
{Field: "max_context", Value: "200000"},
{Field: "max_output_tokens", Value: "16384"},
}
merged := ResolveIntrinsic("some-model", nil, overrides)
if merged.MaxContext != 200000 {
t.Errorf("expected max_context=200000, got %d", merged.MaxContext)
}
if merged.MaxOutputTokens != 16384 {
t.Errorf("expected max_output_tokens=16384, got %d", merged.MaxOutputTokens)
}
}
func TestResolveIntrinsic_OverridePriority(t *testing.T) {
// Catalog says max_context=128000, admin overrides to 256000.
// Admin should win.
catalogCaps := &models.ModelCapabilities{
MaxContext: 128000,
}
overrides := []models.CapabilityOverride{
{Field: "max_context", Value: "256000"},
}
merged := ResolveIntrinsic("some-model", catalogCaps, overrides)
if merged.MaxContext != 256000 {
t.Errorf("admin override should win over catalog: expected 256000, got %d", merged.MaxContext)
}
}

View File

@@ -1,240 +0,0 @@
package capabilities
import (
"regexp"
"strings"
"switchboard-core/models"
)
// ── Known Model Defaults ────────────────────
//
// REMOVED in 0.9.1: The static known model table was removed because the same
// model ID can have different capabilities depending on the provider hosting it
// (e.g. DeepSeek on Venice has no tool_calling, same model on OpenRouter does).
//
// Capability resolution chain:
// 1. Catalog (provider API sync) — authoritative per-provider
// 2. Heuristic inference (regex on model ID) — best effort
// 3. Admin override — manual correction (shipped v0.22.0)
//
// The catalog is populated when providers are added (auto-fetch) or refreshed.
// Heuristics cover broad model families (llama→tools, gemini→vision, etc.)
// until catalog data is available.
// LookupKnownModel always returns false — kept for interface compatibility.
// Callers fall through to heuristic inference.
func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) {
return models.ModelCapabilities{}, false
}
// ── Heuristic Capability Detection ──────────
var (
toolPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)llama[_-]?[34]`),
regexp.MustCompile(`(?i)granite[_-]?[34]`),
regexp.MustCompile(`(?i)hermes[_-]?[23]?`),
regexp.MustCompile(`(?i)qwen[_-]?[23]`),
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[_-]?[345]`),
regexp.MustCompile(`(?i)^openai[_-]gpt`),
regexp.MustCompile(`(?i)^claude`),
regexp.MustCompile(`(?i)gemma[_-]?[23]`),
regexp.MustCompile(`(?i)glm[_-]?[45]`),
regexp.MustCompile(`(?i)gemini`),
regexp.MustCompile(`(?i)grok`),
regexp.MustCompile(`(?i)kimi`),
regexp.MustCompile(`(?i)minimax`),
}
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|opus|sonnet)`),
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`),
regexp.MustCompile(`(?i)gemma[_-]?3`),
regexp.MustCompile(`(?i)grok[_-]?4`),
regexp.MustCompile(`(?i)mistral.*24b`),
}
reasoningPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)deepseek[_-]?r1`),
regexp.MustCompile(`(?i)qwq`),
regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`),
regexp.MustCompile(`(?i)thinking`),
regexp.MustCompile(`(?i)grok`),
regexp.MustCompile(`(?i)glm[_-]?[45]`),
}
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.
// Best-effort fallback when the provider API hasn't reported capabilities.
func InferCapabilities(modelID string) models.ModelCapabilities {
id := normalizeModelID(modelID)
return models.ModelCapabilities{
Streaming: true, // virtually everything streams
ToolCalling: matchesAny(id, toolPatterns),
Vision: matchesAny(id, visionPatterns),
Reasoning: matchesAny(id, reasoningPatterns),
CodeOptimized: matchesAny(id, codePatterns),
}
}
// ResolveIntrinsic determines the intrinsic capabilities of a model.
// Priority (highest wins):
// 1. Admin overrides (manual corrections from capability_overrides table)
// 2. Catalog (from model_catalog DB — synced from provider API)
// 3. Heuristic inference (regex patterns on model ID)
//
// The catalog is the source of truth per provider. Heuristics fill gaps
// for models that haven't been synced yet. Admin overrides correct
// provider-reported or heuristic errors. No hardcoded model table —
// the same model can have different capabilities on different providers.
func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities, overrides []models.CapabilityOverride) models.ModelCapabilities {
// Start with catalog data if available
var base models.ModelCapabilities
if catalogCaps != nil && catalogCaps.HasProviderData() {
base = *catalogCaps
}
// Fill gaps from heuristic inference
inferred := InferCapabilities(modelID)
mergeGaps(&base, &inferred)
// Apply admin overrides (highest priority — can flip any field)
applyOverrides(&base, overrides)
return base
}
// applyOverrides applies admin capability corrections to the resolved capabilities.
func applyOverrides(caps *models.ModelCapabilities, overrides []models.CapabilityOverride) {
for _, o := range overrides {
boolVal := o.Value == "true" || o.Value == "1"
switch o.Field {
case "streaming":
caps.Streaming = boolVal
case "tool_calling":
caps.ToolCalling = boolVal
case "vision":
caps.Vision = boolVal
case "thinking":
caps.Thinking = boolVal
case "reasoning":
caps.Reasoning = boolVal
case "code_optimized":
caps.CodeOptimized = boolVal
case "web_search":
caps.WebSearch = boolVal
case "max_context":
if n := parseInt(o.Value); n > 0 {
caps.MaxContext = n
}
case "max_output_tokens":
if n := parseInt(o.Value); n > 0 {
caps.MaxOutputTokens = n
}
}
}
}
// parseInt parses a string to int, returning 0 on failure.
func parseInt(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
// ResolveMaxOutput returns the max output tokens for a model.
// Priority: explicit caps → derive from context → 4096 fallback.
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
if caps.MaxOutputTokens > 0 {
return caps.MaxOutputTokens
}
if caps.MaxContext > 0 {
derived := caps.MaxContext / 8
if derived < 2048 {
derived = 2048
}
if derived > 16384 {
derived = 16384
}
return derived
}
return 4096
}
// mergeGaps fills zero/false fields in dst from src. Never overrides existing data.
func mergeGaps(dst, src *models.ModelCapabilities) {
if !dst.Streaming && src.Streaming {
dst.Streaming = true
}
if !dst.ToolCalling && src.ToolCalling {
dst.ToolCalling = true
}
if !dst.Vision && src.Vision {
dst.Vision = true
}
if !dst.Thinking && src.Thinking {
dst.Thinking = true
}
if !dst.Reasoning && src.Reasoning {
dst.Reasoning = true
}
if !dst.CodeOptimized && src.CodeOptimized {
dst.CodeOptimized = true
}
if !dst.WebSearch && src.WebSearch {
dst.WebSearch = true
}
if dst.MaxContext == 0 && src.MaxContext > 0 {
dst.MaxContext = src.MaxContext
}
if dst.MaxOutputTokens == 0 && src.MaxOutputTokens > 0 {
dst.MaxOutputTokens = src.MaxOutputTokens
}
}
// normalizeModelID strips provider prefix and lowercases.
func normalizeModelID(modelID string) string {
id := strings.ToLower(modelID)
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
return id
}

View File

@@ -1,288 +0,0 @@
package capabilities
import (
"context"
"log"
"switchboard-core/models"
"switchboard-core/store"
)
// ModelsForUser returns all models and Personas visible to a user.
//
// Visibility is controlled by the three-state visibility field on each
// catalog entry (enabled / team / disabled), applied per provider scope:
//
// Tier | enabled | team | disabled
// ----------+----------------+----------------------+---------
// Global | All users | Team admin → personas | Hidden
// Team | Team members | Team admin → personas | Hidden
// Personal | Owner direct | N/A | Hidden
//
// Sources aggregated:
// 1. Global catalog: enabled models → all authenticated users
// 2. Team catalog: enabled models → team members
// 3. Personal BYOK: enabled models → owner (if allow_user_byok)
// 4. Personas: global + team-scoped + personal + shared
// 5. User hidden preferences applied last
func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) {
result := make([]models.UserModel, 0) // never nil — serializes as [] not null
// Load policies once
policies, err := stores.Policies.GetAll(ctx)
if err != nil {
return nil, err
}
allowBYOK := policies["allow_user_byok"] == "true"
// Load user's hidden preferences
hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user model settings: %v", err)
hiddenMap = make(map[string]bool)
}
// Get user's team IDs
teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user teams: %v", err)
}
// ── 1. Global enabled catalog models → all users ────
globalModels, err := stores.Catalog.ListVisible(ctx)
if err != nil {
return nil, err
}
// Build provider name lookup for global providers
globalProviders, _ := stores.Providers.ListGlobal(ctx)
providerMap := make(map[string]models.ProviderConfig)
for _, p := range globalProviders {
providerMap[p.ID] = p
}
countGlobal := len(globalModels)
for _, entry := range globalModels {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
prov := providerMap[entry.ProviderConfigID]
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
ModelType: entry.ModelType,
Source: "catalog",
ProviderConfigID: entry.ProviderConfigID,
ConfigID: entry.ProviderConfigID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeGlobal,
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
})
}
// ── 2. Team enabled catalog models → team members ────
countTeam := 0
for _, teamID := range teamIDs {
teamProviders, err := stores.Providers.ListForTeam(ctx, teamID)
if err != nil {
log.Printf("warn: failed to load team %s providers: %v", teamID, err)
continue
}
for _, prov := range teamProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
ModelType: entry.ModelType,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeTeam,
OwnerID: prov.OwnerID,
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
})
countTeam++
}
}
}
// ── 3. Personal BYOK enabled models → owner ────
countPersonal := 0
if allowBYOK {
personalProviders, err := stores.Providers.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personal providers: %v", err)
} else {
for _, prov := range personalProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
ModelType: entry.ModelType,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopePersonal,
OwnerID: &userID,
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
})
countPersonal++
}
}
}
}
// ── 4. Personas (always resolved) ────────────────
personas, err := stores.Personas.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personas: %v", err)
} else {
for _, p := range personas {
// Resolve base model capabilities
var catalogCaps *models.ModelCapabilities
if p.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: look up any provider's catalog entry for this model
// (covers auto-resolve personas where provider_config_id is NULL)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps, nil)
// Load tool grants
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
if len(toolGrants) > 0 {
caps.ToolCalling = true
}
// Look up provider info
var provName, provType string
if p.ProviderConfigID != nil {
if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil {
provName = prov.Name
provType = prov.Provider
}
}
result = append(result, models.UserModel{
ID: p.ID,
DisplayName: p.Name,
ModelID: p.BaseModelID,
Source: "persona",
ProviderConfigID: deref(p.ProviderConfigID),
ConfigID: deref(p.ProviderConfigID),
ProviderName: provName,
ProviderType: provType,
Capabilities: caps,
IsPersona: true,
PersonaID: p.ID,
PersonaHandle: p.Handle,
PersonaScope: p.Scope,
PersonaAvatar: p.Avatar,
PersonaTeamName: teamName(p, stores, ctx),
Description: p.Description,
Icon: p.Icon,
Avatar: p.Avatar,
SystemPrompt: p.SystemPrompt,
Temperature: p.Temperature,
MaxTokens: p.MaxTokens,
ToolGrants: toolGrants,
Scope: p.Scope,
OwnerID: p.OwnerID,
Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs
})
}
}
countPersonas := len(result) - countGlobal - countTeam - countPersonal
log.Printf("📋 ModelsForUser(%s): %d global, %d team, %d personal, %d personas → %d total",
userID, countGlobal, countTeam, countPersonal, countPersonas, len(result))
return result, nil
}
// ResolveForPersona returns effective capabilities for a specific Persona.
// Used at completion time to determine what tools/features are available.
func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) {
var catalogCaps *models.ModelCapabilities
if persona.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: any provider's catalog entry (auto-resolve personas)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil)
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
if err != nil {
return caps, nil, err
}
return caps, toolGrants, nil
}
func displayName(name, modelID string) string {
if name != "" {
return name
}
return modelID
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// teamName resolves the team_name for a persona's owner (if team-scoped).
func teamName(p models.Persona, stores store.Stores, ctx context.Context) string {
if p.Scope != models.ScopeTeam || p.OwnerID == nil {
return ""
}
team, err := stores.Teams.GetByID(ctx, *p.OwnerID)
if err != nil {
return ""
}
return team.Name
}

View File

@@ -1,314 +0,0 @@
package compaction
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
"switchboard-core/treepath"
)
// ── Request / Result ────────────────────────
// CompactRequest specifies what to compact.
type CompactRequest struct {
ChannelID string
UserID string // channel owner
TeamID *string // for role resolution
Trigger string // "manual" | "auto"
}
// CompactResult describes what compaction produced.
type CompactResult struct {
SummaryID string
SummarizedCount int
Model string
UsedFallback bool
Content string
InputTokens int
OutputTokens int
}
// ── Errors ──────────────────────────────────
var (
// ErrContextBudget is returned when the conversation to summarize
// exceeds the utility model's context window.
ErrContextBudget = fmt.Errorf("conversation exceeds utility model context window")
)
// ── Service ─────────────────────────────────
// Service provides conversation compaction (summarization).
// Used by both the HTTP handler (manual) and the background scanner (auto).
type Service struct {
stores store.Stores
resolver *roles.Resolver
}
// NewService creates a compaction service.
func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
return &Service{stores: stores, resolver: resolver}
}
// Resolver exposes the underlying role resolver (for external checks like
// IsConfigured / IsPersonalOverride).
func (s *Service) Resolver() *roles.Resolver {
return s.resolver
}
// ── Compact ─────────────────────────────────
// Compact summarizes the conversation in a channel, inserting a summary
// tree node. Returns the result or an error.
//
// The method:
// 1. Loads the active path via the message tree
// 2. Finds the most recent summary boundary
// 3. Collects post-boundary messages
// 4. Checks the content fits the utility model's context window
// 5. Calls the utility model role to generate a summary
// 6. Inserts the summary as a tree node with metadata
// 7. Updates the user's cursor
// 8. Logs usage
func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error) {
// ── Load active path ──
path, err := treepath.GetActivePath(req.ChannelID, req.UserID)
if err != nil {
return nil, fmt.Errorf("load conversation: %w", err)
}
if len(path) < 4 {
return nil, fmt.Errorf("conversation too short to summarize")
}
// Find existing summary boundary (if any) and only summarize after it
startIdx := 0
for i := range path {
if treepath.IsSummaryMessage(&path[i]) {
startIdx = i + 1
}
}
// Build messages to summarize (skip system messages, skip previous summaries)
var toSummarize []string
messagesInScope := 0
var lastMessageID string
for _, m := range path[startIdx:] {
if m.Role == "system" || treepath.IsSummaryMessage(&m) {
continue
}
toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content))
messagesInScope++
lastMessageID = m.ID
}
if messagesInScope < 4 {
return nil, fmt.Errorf("not enough new messages since last summary")
}
// ── Build the summarization prompt ──
conversationText := strings.Join(toSummarize, "\n\n")
systemPrompt := `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves:
- Key decisions and conclusions reached
- Important facts, names, numbers, and technical details mentioned
- Action items or commitments made
- The overall context and topic flow
Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`
userContent := "Summarize this conversation:\n\n" + conversationText
// ── Context budget guard rail ──
// Estimate whether the prompt fits the utility model's context window.
// Prevents sending truncated input to small models (e.g. 32K utility
// summarizing a 128K conversation).
promptTokens := EstimateTokens(systemPrompt) + 4 + EstimateTokens(userContent) + 4
utilityBudget := s.getUtilityContextBudget(ctx, req.UserID, req.TeamID)
// Reserve 20% for output (the summary itself)
inputCeiling := int(float64(utilityBudget) * 0.80)
if promptTokens > inputCeiling {
log.Printf("⚠ Compaction skipped for channel %s: prompt ~%dK tokens exceeds utility model budget %dK (ceiling %dK)",
req.ChannelID, promptTokens/1000, utilityBudget/1000, inputCeiling/1000)
return nil, fmt.Errorf("%w: ~%d tokens needed, utility model allows ~%d",
ErrContextBudget, promptTokens, inputCeiling)
}
summaryPrompt := []providers.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userContent},
}
// ── Call utility role ──
log.Printf("📝 Compacting channel %s for user %s (%d messages, ~%dK tokens, trigger=%s)",
req.ChannelID, req.UserID, messagesInScope, promptTokens/1000, req.Trigger)
result, err := s.resolver.Complete(ctx, roles.RoleUtility, req.UserID, req.TeamID, summaryPrompt)
if err != nil {
log.Printf("⚠ Compaction failed for channel %s: %v", req.ChannelID, err)
return nil, fmt.Errorf("summarization failed: %w", err)
}
// ── Log usage ──
s.logUsage(ctx, req.ChannelID, req.UserID, req.Trigger, result)
// ── Insert summary message as a tree node ──
metadata := models.JSONMap{
"type": "summary",
"summarized_until_id": lastMessageID,
"summarized_count": messagesInScope,
"utility_model": result.Model,
"used_fallback": result.UsedFallback,
"trigger": req.Trigger,
}
metaJSON, _ := json.Marshal(metadata)
siblingIdx := treepath.NextSiblingIndex(req.ChannelID, &lastMessageID)
summaryMsg := &models.Message{
ChannelID: req.ChannelID,
ParentID: &lastMessageID,
Role: "assistant",
Content: result.Content,
Model: result.Model,
Metadata: models.JSONMap{},
SiblingIndex: siblingIdx,
ParticipantType: "system",
}
_ = json.Unmarshal(metaJSON, &summaryMsg.Metadata)
if err := s.stores.Messages.Create(ctx, summaryMsg); err != nil {
log.Printf("⚠ Failed to persist summary for channel %s: %v", req.ChannelID, err)
return nil, fmt.Errorf("failed to save summary: %w", err)
}
summaryMsgID := summaryMsg.ID
// Update cursor to point to the summary node
if err := treepath.UpdateCursor(req.ChannelID, req.UserID, summaryMsgID); err != nil {
log.Printf("⚠ Failed to update cursor after compaction: %v", err)
}
log.Printf("✅ Compaction done for channel %s: %s (%d messages → %d chars, trigger=%s)",
req.ChannelID, summaryMsgID, messagesInScope, len(result.Content), req.Trigger)
return &CompactResult{
SummaryID: summaryMsgID,
SummarizedCount: messagesInScope,
Model: result.Model,
UsedFallback: result.UsedFallback,
Content: result.Content,
InputTokens: result.InputTokens,
OutputTokens: result.OutputTokens,
}, nil
}
// ── Context Budget ──────────────────────────
// getUtilityContextBudget resolves the utility model's context window.
// Falls back to DefaultBudget if the model isn't in the catalog.
func (s *Service) getUtilityContextBudget(ctx context.Context, userID string, teamID *string) int {
cfg, err := s.resolver.GetConfig(ctx, roles.RoleUtility, userID, teamID)
if err != nil || cfg == nil || cfg.Primary == nil {
return DefaultBudget
}
// Look up the primary model in catalog
entry, err := s.stores.Catalog.GetByModelID(ctx, cfg.Primary.ProviderConfigID, cfg.Primary.ModelID)
if err == nil && entry.Capabilities.MaxContext > 0 {
return entry.Capabilities.MaxContext
}
// Try any-provider lookup (model may be synced under a different config)
entry, err = s.stores.Catalog.GetByModelIDAny(ctx, cfg.Primary.ModelID)
if err == nil && entry.Capabilities.MaxContext > 0 {
return entry.Capabilities.MaxContext
}
return DefaultBudget
}
// ── Rate Limit Check ────────────────────────
// CheckRateLimit checks the utility rate limit for org-funded calls.
// Returns nil if within limits, an error with a descriptive message otherwise.
func (s *Service) CheckRateLimit(ctx context.Context, userID string) error {
// Load rate limit from global settings (default: 20/hour, 0 = unlimited)
limit := 20
settings, err := s.stores.GlobalConfig.Get(ctx, "utility_rate_limit")
if err == nil {
if v, ok := settings["value"]; ok {
switch n := v.(type) {
case float64:
limit = int(n)
case int:
limit = n
}
}
}
if limit <= 0 {
return nil // unlimited
}
since := time.Now().Add(-1 * time.Hour)
count, err := s.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since)
if err != nil {
log.Printf("⚠ Rate limit check failed: %v", err)
return nil // fail open — don't block on DB errors
}
if count >= limit {
return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit)
}
return nil
}
// ── Helpers ─────────────────────────────────
// GetUserTeamID returns the user's first team ID (for role resolution).
func (s *Service) GetUserTeamID(ctx context.Context, userID string) *string {
teamID, _ := s.stores.Teams.GetFirstTeamIDForUser(ctx, userID)
if teamID == "" {
return nil
}
return &teamID
}
func (s *Service) logUsage(ctx context.Context, channelID, userID, trigger string, result *roles.CompletionResult) {
role := roles.RoleUtility
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &result.ConfigID,
ProviderScope: result.ProviderScope,
ModelID: result.Model,
Role: &role,
PromptTokens: result.InputTokens,
CompletionTokens: result.OutputTokens,
CacheCreationTokens: result.CacheCreationTokens,
CacheReadTokens: result.CacheReadTokens,
}
// Calculate cost from pricing
pricing, err := s.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model)
if err == nil && pricing != nil {
if pricing.InputPerM != nil {
costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM
entry.CostInput = &costIn
}
if pricing.OutputPerM != nil {
costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM
entry.CostOutput = &costOut
}
}
if err := s.stores.Usage.Log(ctx, entry); err != nil {
log.Printf("⚠ Failed to log compaction usage: %v", err)
}
}

View File

@@ -1,409 +0,0 @@
package compaction
import (
"context"
"encoding/json"
"testing"
"time"
"switchboard-core/database"
"switchboard-core/models"
postgres "switchboard-core/store/postgres"
)
// ── Helpers ─────────────────────────────────
func requireDB(t *testing.T) {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
}
func seedChannel(t *testing.T, userID, title string, msgCount, contentSize int) (channelID string, msgIDs []string) {
t.Helper()
channelID = database.SeedTestChannel(t, userID, title)
msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize)
if len(msgIDs) > 0 {
database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1])
}
return
}
// setGlobalSetting writes a global_settings key for scanner config tests.
func setGlobalSetting(t *testing.T, key string, value interface{}) {
t.Helper()
valMap := models.JSONMap{"value": value}
valJSON, _ := json.Marshal(valMap)
_, err := database.DB.Exec(`
INSERT INTO global_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2
`, key, string(valJSON))
if err != nil {
t.Fatalf("setGlobalSetting(%s): %v", key, err)
}
}
// seedChannelBackdated creates a channel with updated_at set to (now - age).
// Uses INSERT with an explicit timestamp so the BEFORE UPDATE trigger on
// channels (which overwrites updated_at = NOW()) never fires.
func seedChannelBackdated(t *testing.T, userID, title string, age time.Duration, msgCount, contentSize int) (channelID string, msgIDs []string) {
t.Helper()
target := time.Now().Add(-age)
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, updated_at)
VALUES ($1, $2, 'direct', $3)
RETURNING id
`, userID, title, target).Scan(&channelID)
if err != nil {
t.Fatalf("seedChannelBackdated: %v", err)
}
msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize)
if len(msgIDs) > 0 {
database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1])
}
return
}
// setChannelSettings sets the channel.settings JSONB.
func setChannelSettings(t *testing.T, channelID string, settings models.JSONMap) {
t.Helper()
sJSON, _ := json.Marshal(settings)
_, err := database.DB.Exec(`UPDATE channels SET settings = $1 WHERE id = $2`, string(sJSON), channelID)
if err != nil {
t.Fatalf("setChannelSettings: %v", err)
}
}
// ═══════════════════════════════════════════
// Estimator Tests (no DB — duplicated here for package-level access)
// ═══════════════════════════════════════════
// Note: estimator_test.go covers the unit tests more thoroughly.
// These are here to verify the package compiles with DB tests.
func TestEstimateTokens_Basic(t *testing.T) {
if got := EstimateTokens("hello world!"); got != 3 { // 12 chars → 3
t.Errorf("EstimateTokens = %d, want 3", got)
}
}
// ═══════════════════════════════════════════
// Scanner: Candidate Query
// ═══════════════════════════════════════════
func TestScanner_FindCandidates_ReturnsQualifying(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil) // no resolver needed for this test
sc := NewScanner(svc, stores, ScannerConfig{})
userID := database.SeedTestUser(t, "scanuser1", "scan1@test.com")
// Create a channel with enough messages to qualify
// 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K)
// Backdated 5min so it passes activity gap (>2min) and recency (<7 days)
channelID, _ := seedChannelBackdated(t, userID, "Big Chat", 5*time.Minute, 20, 2000)
ctx := context.Background()
candidates := sc.findCandidates(ctx)
if len(candidates) == 0 {
t.Fatal("expected at least 1 candidate, got 0")
}
found := false
for _, c := range candidates {
if c.ID == channelID {
found = true
break
}
}
if !found {
t.Fatalf("channel %s not in candidates (got %d candidates)", channelID, len(candidates))
}
}
func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com")
// Only 4 messages — below candidateMinMessages=10
channelID, _ := seedChannelBackdated(t, userID, "Small Chat", 5*time.Minute, 4, 2000)
ctx := context.Background()
candidates := sc.findCandidates(ctx)
for _, c := range candidates {
if c.ID == channelID {
t.Fatal("channel with <10 messages should not be a candidate")
}
}
}
func TestScanner_FindCandidates_ExcludesTooRecent(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
userID := database.SeedTestUser(t, "scanuser3", "scan3@test.com")
// Enough messages but updated just now (< candidateActivityGap=2min)
seedChannel(t, userID, "Fresh Chat", 20, 2000)
// Don't backdate — should be excluded
ctx := context.Background()
candidates := sc.findCandidates(ctx)
for _, c := range candidates {
if c.UserID == userID {
t.Fatal("channel updated just now should not be a candidate (activity gap)")
}
}
}
func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com")
channelID, _ := seedChannelBackdated(t, userID, "Archived Chat", 5*time.Minute, 20, 2000)
// Archive the channel
database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID)
ctx := context.Background()
candidates := sc.findCandidates(ctx)
for _, c := range candidates {
if c.ID == channelID {
t.Fatal("archived channel should not be a candidate")
}
}
}
// ═══════════════════════════════════════════
// Scanner: Cooldown + Dedup
// ═══════════════════════════════════════════
func TestScanner_Cooldown(t *testing.T) {
sc := &Scanner{
lastCompacted: make(map[string]time.Time),
}
channelID := "test-cooldown-channel"
// No cooldown initially
sc.mu.Lock()
_, inCooldown := sc.lastCompacted[channelID]
sc.mu.Unlock()
if inCooldown {
t.Fatal("should not be in cooldown initially")
}
// Record compaction
sc.mu.Lock()
sc.lastCompacted[channelID] = time.Now()
sc.mu.Unlock()
// Now it's in cooldown
sc.mu.Lock()
last, ok := sc.lastCompacted[channelID]
sc.mu.Unlock()
if !ok {
t.Fatal("should be in cooldown after recording")
}
if time.Since(last) > time.Second {
t.Fatal("cooldown timestamp should be recent")
}
}
func TestScanner_InFlightDedup(t *testing.T) {
sc := &Scanner{}
// sync.Map zero value is ready to use
channelID := "test-dedup-channel"
// First load — not in flight
_, loaded := sc.inFlight.LoadOrStore(channelID, struct{}{})
if loaded {
t.Fatal("should not be loaded on first attempt")
}
// Second load — already in flight
_, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{})
if !loaded {
t.Fatal("should be loaded on second attempt (dedup)")
}
// Clean up
sc.inFlight.Delete(channelID)
_, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{})
if loaded {
t.Fatal("should not be loaded after delete")
}
}
// ═══════════════════════════════════════════
// Scanner: Channel Opt-Out
// ═══════════════════════════════════════════
func TestScanner_ShouldCompact_ChannelOptOut(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil) // no resolver → IsConfigured returns false
sc := NewScanner(svc, stores, ScannerConfig{})
userID := database.SeedTestUser(t, "optout_user", "optout@test.com")
channelID, _ := seedChannel(t, userID, "Opt-Out Chat", 20, 2000)
// Opt out via channel settings
setChannelSettings(t, channelID, models.JSONMap{"auto_compaction": false})
ch := models.Channel{Settings: models.JSONMap{"auto_compaction": false}}
ch.ID = channelID
ch.UserID = userID
ctx := context.Background()
if sc.shouldCompact(ctx, &ch) {
t.Fatal("shouldCompact should return false for opted-out channel")
}
}
// ═══════════════════════════════════════════
// Scanner: Settings Helpers
// ═══════════════════════════════════════════
func TestScanner_IsEnabled(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
ctx := context.Background()
// Default: disabled
if sc.isEnabled(ctx) {
t.Fatal("should be disabled by default")
}
// Enable
setGlobalSetting(t, "auto_compaction_enabled", true)
if !sc.isEnabled(ctx) {
t.Fatal("should be enabled after setting to true")
}
// Disable again
setGlobalSetting(t, "auto_compaction_enabled", false)
if sc.isEnabled(ctx) {
t.Fatal("should be disabled after setting to false")
}
}
func TestScanner_GetThreshold_Default(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
ch := &models.Channel{}
got := sc.getThreshold(ch)
if got != DefaultThreshold {
t.Errorf("default threshold = %f, want %f", got, DefaultThreshold)
}
}
func TestScanner_GetThreshold_GlobalOverride(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
setGlobalSetting(t, "auto_compaction_threshold", 0.85)
ch := &models.Channel{}
got := sc.getThreshold(ch)
if got != 0.85 {
t.Errorf("global threshold = %f, want 0.85", got)
}
}
func TestScanner_GetThreshold_ChannelOverride(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
setGlobalSetting(t, "auto_compaction_threshold", 0.85)
ch := &models.Channel{
Settings: models.JSONMap{"compaction_threshold": 0.60},
}
got := sc.getThreshold(ch)
if got != 0.60 {
t.Errorf("channel threshold = %f, want 0.60", got)
}
}
func TestScanner_GetCooldownDuration_Default(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
ctx := context.Background()
got := sc.getCooldownDuration(ctx)
if got != DefaultCooldown {
t.Errorf("default cooldown = %s, want %s", got, DefaultCooldown)
}
}
func TestScanner_GetCooldownDuration_Override(t *testing.T) {
requireDB(t)
stores := postgres.NewStores(database.TestDB)
svc := NewService(stores, nil)
sc := NewScanner(svc, stores, ScannerConfig{})
ctx := context.Background()
setGlobalSetting(t, "auto_compaction_cooldown_minutes", float64(15))
got := sc.getCooldownDuration(ctx)
want := 15 * time.Minute
if got != want {
t.Errorf("cooldown = %s, want %s", got, want)
}
}
// ═══════════════════════════════════════════
// Context Budget Guard Rail
// ═══════════════════════════════════════════
func TestEstimateTokens_GuardRailMath(t *testing.T) {
// Simulate a large conversation that exceeds a 32K utility model
// 120K chars ≈ 30K tokens of conversation + system prompt overhead
// inputCeiling = 32K * 0.80 = 25.6K → 30K exceeds it
largeContent := make([]byte, 120000)
contentTokens := EstimateTokens(string(largeContent))
systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead
totalPrompt := contentTokens + systemTokens
utilityBudget := 32000 // 32K model
inputCeiling := int(float64(utilityBudget) * 0.80)
if totalPrompt <= inputCeiling {
t.Errorf("120K chars (%d tokens) should exceed 32K model ceiling (%d tokens)",
totalPrompt, inputCeiling)
}
// Smaller conversation should fit
smallContent := make([]byte, 50000)
smallTokens := EstimateTokens(string(smallContent)) + systemTokens
if smallTokens > inputCeiling {
t.Errorf("50K chars (%d tokens) should fit in 32K model ceiling (%d tokens)",
smallTokens, inputCeiling)
}
}

View File

@@ -1,57 +0,0 @@
package compaction
import (
"switchboard-core/treepath"
)
// ── Token Estimation ────────────────────────
//
// Rough heuristic matching the frontend Tokens.estimate() (~4 chars/token).
// Good enough for threshold checks. Swap in a real tokenizer later if
// over/under-triggering becomes a problem.
// EstimateTokens returns a rough token count for a string.
// Uses the ~4 chars/token heuristic for English (GPT/Claude average).
func EstimateTokens(text string) int {
return (len(text) + 3) / 4
}
// EstimatePath returns total estimated tokens for a message path.
// Adds +4 per message for role/delimiter overhead, matching the frontend.
func EstimatePath(path []treepath.PathMessage) int {
total := 0
for i := range path {
total += EstimateTokens(path[i].Content) + 4
}
return total
}
// EstimatePathFromSummary returns estimated tokens for messages after
// the most recent summary boundary. Returns 0 and false if a summary
// exists but fewer than minMessages follow it.
func EstimatePathFromSummary(path []treepath.PathMessage, minMessages int) (tokens int, msgCount int, ok bool) {
startIdx := 0
for i := range path {
if treepath.IsSummaryMessage(&path[i]) {
startIdx = i + 1
}
}
post := path[startIdx:]
// Count non-system, non-summary messages
count := 0
est := 0
for i := range post {
if post[i].Role == "system" || treepath.IsSummaryMessage(&post[i]) {
continue
}
count++
est += EstimateTokens(post[i].Content) + 4
}
if count < minMessages {
return 0, count, false
}
return est, count, true
}

View File

@@ -1,92 +0,0 @@
package compaction
import (
"testing"
"switchboard-core/treepath"
)
func TestEstimateTokens(t *testing.T) {
tests := []struct {
input string
want int
}{
{"", 0},
{"a", 1},
{"abcd", 1},
{"abcde", 2},
{"Hello, world!", 4}, // 13 chars → ceil(13/4) = 4
{string(make([]byte, 100)), 25},
{string(make([]byte, 1000)), 250},
}
for _, tt := range tests {
got := EstimateTokens(tt.input)
if got != tt.want {
t.Errorf("EstimateTokens(%d chars) = %d, want %d", len(tt.input), got, tt.want)
}
}
}
func TestEstimatePath(t *testing.T) {
path := []treepath.PathMessage{
{Content: string(make([]byte, 40))}, // 10 tokens + 4 overhead = 14
{Content: string(make([]byte, 80))}, // 20 tokens + 4 overhead = 24
{Content: string(make([]byte, 120))}, // 30 tokens + 4 overhead = 34
}
got := EstimatePath(path)
// (10+4) + (20+4) + (30+4) = 72
if got != 72 {
t.Errorf("EstimatePath = %d, want 72", got)
}
}
func TestEstimatePathFromSummary_NoSummary(t *testing.T) {
path := make([]treepath.PathMessage, 10)
for i := range path {
path[i] = treepath.PathMessage{Role: "user", Content: string(make([]byte, 40))}
}
tokens, count, ok := EstimatePathFromSummary(path, 8)
if !ok {
t.Fatal("expected ok=true for 10 messages >= minMessages=8")
}
if count != 10 {
t.Errorf("count = %d, want 10", count)
}
// Each: 10 tokens + 4 overhead = 14, × 10 = 140
if tokens != 140 {
t.Errorf("tokens = %d, want 140", tokens)
}
}
func TestEstimatePathFromSummary_TooFew(t *testing.T) {
path := make([]treepath.PathMessage, 3)
for i := range path {
path[i] = treepath.PathMessage{Role: "user", Content: "hello"}
}
_, _, ok := EstimatePathFromSummary(path, 8)
if ok {
t.Error("expected ok=false for 3 messages < minMessages=8")
}
}
func TestEstimatePathFromSummary_SkipsSystemMessages(t *testing.T) {
path := []treepath.PathMessage{
{Role: "system", Content: "You are helpful."},
{Role: "user", Content: string(make([]byte, 40))},
{Role: "assistant", Content: string(make([]byte, 40))},
}
tokens, count, ok := EstimatePathFromSummary(path, 1)
if !ok {
t.Fatal("expected ok=true")
}
// system skipped, 2 non-system messages
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
if tokens != 28 { // 2 × (10 + 4) = 28
t.Errorf("tokens = %d, want 28", tokens)
}
}

View File

@@ -1,361 +0,0 @@
package compaction
import (
"context"
"log"
"sync"
"time"
"switchboard-core/models"
"switchboard-core/roles"
"switchboard-core/store"
"switchboard-core/treepath"
)
// ── Defaults ────────────────────────────────
const (
DefaultInterval = 5 * time.Minute
DefaultConcurrency = 2
DefaultThreshold = 0.70
DefaultCooldown = 30 * time.Minute
DefaultBudget = 128000 // 128K tokens — conservative fallback
// Candidate query filters
candidateMinMessages = 10
candidateMinChars = 20000 // ~5K tokens
candidateActivityGap = 2 * time.Minute
candidateMaxAge = 7 * 24 * time.Hour
candidateBatchSize = 50
// shouldCompact requires this many post-summary messages
minPostSummaryMessages = 8
)
// ── Scanner Config ──────────────────────────
// ScannerConfig holds startup configuration for the scanner.
// Settings are re-read from global_settings each tick so changes
// take effect without restart.
type ScannerConfig struct {
Interval time.Duration
Concurrency int
}
// ── Scanner ─────────────────────────────────
// Scanner runs a periodic background loop that finds channels exceeding
// their context threshold and auto-compacts them via the utility model role.
//
// Follows the Ingester pattern: goroutine pool with semaphore, WaitGroup
// for graceful shutdown.
type Scanner struct {
service *Service
stores store.Stores
sem chan struct{}
wg sync.WaitGroup
stopCh chan struct{}
// Cooldown: channelID → last compaction time
mu sync.Mutex
lastCompacted map[string]time.Time
// In-flight dedup
inFlight sync.Map // channelID → struct{}
interval time.Duration
}
// NewScanner creates a compaction scanner. Call Start() to begin scanning.
func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner {
interval := cfg.Interval
if interval <= 0 {
interval = DefaultInterval
}
concurrency := cfg.Concurrency
if concurrency <= 0 {
concurrency = DefaultConcurrency
}
return &Scanner{
service: svc,
stores: stores,
sem: make(chan struct{}, concurrency),
stopCh: make(chan struct{}),
lastCompacted: make(map[string]time.Time),
interval: interval,
}
}
// Start begins the scan loop in a background goroutine.
func (sc *Scanner) Start() {
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
sc.loop()
}()
log.Printf("🔍 compaction scanner started (interval=%s)", sc.interval)
}
// Stop signals the scanner to stop and waits for in-flight compactions
// to drain. Safe to call from main's defer chain.
func (sc *Scanner) Stop() {
close(sc.stopCh)
sc.wg.Wait()
log.Printf("🔍 compaction scanner stopped")
}
// ── Main Loop ───────────────────────────────
func (sc *Scanner) loop() {
ticker := time.NewTicker(sc.interval)
defer ticker.Stop()
for {
select {
case <-sc.stopCh:
return
case <-ticker.C:
sc.tick()
}
}
}
func (sc *Scanner) tick() {
ctx := context.Background()
// Re-read kill switch each tick (no restart required)
if !sc.isEnabled(ctx) {
return
}
candidates := sc.findCandidates(ctx)
if len(candidates) == 0 {
return
}
log.Printf("🔍 compaction: scanning %d candidates", len(candidates))
cooldown := sc.getCooldownDuration(ctx)
for i := range candidates {
ch := candidates[i]
// Skip if in-flight
if _, loaded := sc.inFlight.LoadOrStore(ch.ID, struct{}{}); loaded {
continue
}
// Skip if in cooldown
sc.mu.Lock()
if last, ok := sc.lastCompacted[ch.ID]; ok && time.Since(last) < cooldown {
sc.mu.Unlock()
sc.inFlight.Delete(ch.ID)
remaining := cooldown - time.Since(last)
log.Printf("⏭ compaction: channel %s skipped (cooldown, %s remaining)", ch.ID, remaining.Round(time.Second))
continue
}
sc.mu.Unlock()
// Precise check (loads full path, estimates tokens)
if !sc.shouldCompact(ctx, &ch) {
sc.inFlight.Delete(ch.ID)
continue
}
// Rate limit check (auto-compaction is always org-funded)
if err := sc.service.CheckRateLimit(ctx, ch.UserID); err != nil {
sc.inFlight.Delete(ch.ID)
log.Printf("⏭ compaction: channel %s skipped (rate limit)", ch.ID)
continue
}
// Dispatch compaction
sc.wg.Add(1)
go func(ch models.Channel) {
defer sc.wg.Done()
defer sc.inFlight.Delete(ch.ID)
// Acquire semaphore
sc.sem <- struct{}{}
defer func() { <-sc.sem }()
sc.doCompact(ch)
}(ch)
}
}
// ── Compact One Channel ─────────────────────
func (sc *Scanner) doCompact(ch models.Channel) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
teamID := sc.service.GetUserTeamID(ctx, ch.UserID)
result, err := sc.service.Compact(ctx, CompactRequest{
ChannelID: ch.ID,
UserID: ch.UserID,
TeamID: teamID,
Trigger: "auto",
})
if err != nil {
log.Printf("⚠ compaction: channel %s failed: %v", ch.ID, err)
return
}
// Record cooldown
sc.mu.Lock()
sc.lastCompacted[ch.ID] = time.Now()
sc.mu.Unlock()
// Audit log
sc.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: nil, // system action
Action: "compaction.auto",
ResourceType: "channel",
ResourceID: ch.ID,
Metadata: models.JSONMap{
"summarized_count": result.SummarizedCount,
"model": result.Model,
"trigger": "auto",
"user_id": ch.UserID,
},
})
log.Printf("✅ compaction: channel %s done (%d messages → %d chars, model=%s)",
ch.ID, result.SummarizedCount, len(result.Content), result.Model)
}
// ── Candidate Query ─────────────────────────
func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel {
activityGap := time.Now().Add(-candidateActivityGap)
maxAge := time.Now().Add(-candidateMaxAge)
candidates, err := sc.stores.Channels.FindCompactionCandidates(ctx,
activityGap, maxAge, candidateMinMessages, candidateMinChars, candidateBatchSize)
if err != nil {
log.Printf("⚠ compaction: candidate query failed: %v", err)
return nil
}
return candidates
}
// ── Precise Check ───────────────────────────
func (sc *Scanner) shouldCompact(ctx context.Context, ch *models.Channel) bool {
// 1. Channel-level opt-out
if ch.Settings != nil {
if v, ok := ch.Settings["auto_compaction"]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
}
// 2. Check utility role is configured (global level — personal overrides
// aren't checked because auto-compaction is org-funded)
if !sc.service.Resolver().IsConfigured(ctx, roles.RoleUtility) {
return false
}
// 3. Load active path
path, err := treepath.GetActivePath(ch.ID, ch.UserID)
if err != nil || len(path) < candidateMinMessages {
return false
}
// 4. Estimate tokens from post-summary messages
tokens, msgCount, ok := EstimatePathFromSummary(path, minPostSummaryMessages)
if !ok {
return false
}
// 5. Compare against threshold
budget := sc.getContextBudget(ctx, ch)
threshold := sc.getThreshold(ch)
ratio := float64(tokens) / float64(budget)
if ratio >= threshold {
log.Printf("🔍 compaction: channel %s qualifies (%.0f%% of %dK context, %d messages post-summary)",
ch.ID, ratio*100, budget/1000, msgCount)
return true
}
return false
}
// ── Settings Helpers ────────────────────────
func (sc *Scanner) isEnabled(ctx context.Context) bool {
val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_enabled")
if err != nil || val == nil {
return false // default: off
}
if v, ok := val["value"]; ok {
switch b := v.(type) {
case bool:
return b
case string:
return b == "true"
}
}
return false
}
func (sc *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int {
// Try channel's model from catalog
if ch.Model != "" {
entry, err := sc.stores.Catalog.GetByModelIDAny(ctx, ch.Model)
if err == nil && entry.Capabilities.MaxContext > 0 {
return entry.Capabilities.MaxContext
}
}
return DefaultBudget
}
func (sc *Scanner) getThreshold(ch *models.Channel) float64 {
// Channel override
if ch.Settings != nil {
if v, ok := ch.Settings["compaction_threshold"]; ok {
switch f := v.(type) {
case float64:
if f > 0 && f < 1 {
return f
}
}
}
}
// Global override
ctx := context.Background()
val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_threshold")
if err == nil && val != nil {
if v, ok := val["value"]; ok {
if f, ok := v.(float64); ok && f > 0 && f < 1 {
return f
}
}
}
return DefaultThreshold
}
func (sc *Scanner) getCooldownDuration(ctx context.Context) time.Duration {
val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_cooldown_minutes")
if err == nil && val != nil {
if v, ok := val["value"]; ok {
switch n := v.(type) {
case float64:
if n > 0 {
return time.Duration(n) * time.Minute
}
case int:
if n > 0 {
return time.Duration(n) * time.Minute
}
}
}
}
return DefaultCooldown
}

View File

@@ -1,15 +0,0 @@
package compaction
import (
"os"
"testing"
"switchboard-core/database"
)
func TestMain(m *testing.M) {
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}

View File

@@ -1,244 +0,0 @@
package export
// chatgpt.go — v0.34.0 CS4
//
// Parses ChatGPT conversation exports (conversations.json) and converts
// them to Switchboard channel/message models.
import (
"encoding/json"
"fmt"
"io"
"sort"
"time"
"switchboard-core/models"
"switchboard-core/store"
)
// ── ChatGPT format types ─────────────────────
// ChatGPTExport is the top-level conversations.json structure.
type ChatGPTExport []ChatGPTConversation
// ChatGPTConversation represents one conversation in the ChatGPT export.
type ChatGPTConversation struct {
Title string `json:"title"`
CreateTime float64 `json:"create_time"`
UpdateTime float64 `json:"update_time"`
Mapping map[string]ChatGPTMappingNode `json:"mapping"`
ConversationID string `json:"conversation_id"`
}
// ChatGPTMappingNode is a node in the conversation DAG.
type ChatGPTMappingNode struct {
ID string `json:"id"`
Parent *string `json:"parent"`
Children []string `json:"children"`
Message *ChatGPTMessage `json:"message"`
}
// ChatGPTMessage is a message within a mapping node.
type ChatGPTMessage struct {
ID string `json:"id"`
Author ChatGPTAuthor `json:"author"`
CreateTime *float64 `json:"create_time"`
Content ChatGPTContent `json:"content"`
Metadata json.RawMessage `json:"metadata"`
}
// ChatGPTAuthor identifies the message sender.
type ChatGPTAuthor struct {
Role string `json:"role"`
}
// ChatGPTContent holds the message content.
type ChatGPTContent struct {
ContentType string `json:"content_type"`
Parts []interface{} `json:"parts"`
}
// ── Parsing ──────────────────────────────────
// ParseChatGPTExport reads and parses a conversations.json file.
func ParseChatGPTExport(r io.Reader) (ChatGPTExport, error) {
var out ChatGPTExport
if err := json.NewDecoder(r).Decode(&out); err != nil {
return nil, fmt.Errorf("chatgpt: parse error: %w", err)
}
return out, nil
}
// ── Conversion ───────────────────────────────
// ChatGPTImportResult holds converted channels and messages.
type ChatGPTImportResult struct {
Channels []models.Channel
Messages []models.Message
}
// ConvertChatGPTExport converts ChatGPT conversations to Switchboard models.
func ConvertChatGPTExport(convs ChatGPTExport, userID string) *ChatGPTImportResult {
result := &ChatGPTImportResult{}
for _, conv := range convs {
ch, msgs := convertConversation(conv, userID)
if ch != nil {
result.Channels = append(result.Channels, *ch)
result.Messages = append(result.Messages, msgs...)
}
}
return result
}
func convertConversation(conv ChatGPTConversation, userID string) (*models.Channel, []models.Message) {
channelID := store.NewID()
title := conv.Title
if title == "" {
title = "Imported Conversation"
}
createdAt := floatToTime(conv.CreateTime)
updatedAt := floatToTime(conv.UpdateTime)
if updatedAt.IsZero() {
updatedAt = createdAt
}
ch := &models.Channel{
BaseModel: models.BaseModel{
ID: channelID,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
UserID: userID,
Title: title,
Type: "direct",
}
// Walk the DAG depth-first to produce ordered messages
msgs := walkDAG(conv.Mapping, channelID, userID)
return ch, msgs
}
func walkDAG(mapping map[string]ChatGPTMappingNode, channelID, userID string) []models.Message {
// Find root nodes (no parent or parent not in mapping)
var roots []string
for id, node := range mapping {
if node.Parent == nil {
roots = append(roots, id)
} else if _, ok := mapping[*node.Parent]; !ok {
roots = append(roots, id)
}
}
var msgs []models.Message
idMap := make(map[string]string) // chatgpt node ID → switchboard message ID
var walk func(nodeID string, parentMsgID *string, siblingIdx int)
walk = func(nodeID string, parentMsgID *string, siblingIdx int) {
node, ok := mapping[nodeID]
if !ok {
return
}
if node.Message != nil && node.Message.Content.ContentType != "" {
content := extractParts(node.Message.Content.Parts)
if content != "" {
msgID := store.NewID()
idMap[nodeID] = msgID
role := mapRole(node.Message.Author.Role)
createdAt := time.Now().UTC()
if node.Message.CreateTime != nil {
createdAt = floatToTime(*node.Message.CreateTime)
}
msg := models.Message{
BaseModel: models.BaseModel{
ID: msgID,
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
ChannelID: channelID,
Role: role,
Content: content,
ParentID: parentMsgID,
SiblingIndex: siblingIdx,
ParticipantType: "user",
ParticipantID: userID,
}
if role == "assistant" || role == "system" || role == "tool" {
msg.ParticipantType = role
msg.ParticipantID = ""
}
msgs = append(msgs, msg)
parentMsgID = &msgID
}
}
// Sort children for deterministic order
children := make([]string, len(node.Children))
copy(children, node.Children)
sort.Strings(children)
for i, childID := range children {
walk(childID, parentMsgID, i)
}
}
sort.Strings(roots)
for _, root := range roots {
walk(root, nil, 0)
}
return msgs
}
func mapRole(role string) string {
switch role {
case "user":
return "user"
case "assistant":
return "assistant"
case "system":
return "system"
case "tool":
return "tool"
default:
return "user"
}
}
func extractParts(parts []interface{}) string {
var texts []string
for _, part := range parts {
switch v := part.(type) {
case string:
if v != "" {
texts = append(texts, v)
}
}
}
if len(texts) == 0 {
return ""
}
result := texts[0]
for i := 1; i < len(texts); i++ {
result += "\n" + texts[i]
}
return result
}
func floatToTime(f float64) time.Time {
if f <= 0 {
return time.Time{}
}
sec := int64(f)
nsec := int64((f - float64(sec)) * 1e9)
return time.Unix(sec, nsec).UTC()
}

View File

@@ -1,216 +0,0 @@
package export
// entities.go — v0.34.0 CS0
//
// Per-entity sanitization: strips sensitive, instance-specific, or
// non-portable fields before writing to the export archive. Each
// Sanitize* function returns a clean map ready for JSON marshalling.
import (
"switchboard-core/models"
)
// ── User ─────────────────────────────────────
// SanitizeUser strips password_hash (json:"-" already), vault fields,
// and external_id. The User struct's json tags already exclude
// password_hash, so standard marshalling is safe. We nil out a few
// more fields for cross-instance portability.
func SanitizeUser(u *models.User) *models.User {
out := *u // shallow copy
out.PasswordHash = ""
out.ExternalID = nil
return &out
}
// ── Channel ──────────────────────────────────
// ExportChannel is a Channel without instance-specific provider binding.
type ExportChannel struct {
models.Channel
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil in export
}
// SanitizeChannel strips provider_config_id.
func SanitizeChannel(ch *models.Channel) ExportChannel {
return ExportChannel{
Channel: *ch,
ProviderConfigID: nil,
}
}
// SanitizeChannels batch-sanitizes a slice.
func SanitizeChannels(chs []models.Channel) []ExportChannel {
out := make([]ExportChannel, len(chs))
for i := range chs {
chs[i].ProviderConfigID = nil
out[i] = SanitizeChannel(&chs[i])
}
return out
}
// ── Message ──────────────────────────────────
// ExportMessage is a Message without provider_config_id.
type ExportMessage struct {
models.Message
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
}
// SanitizeMessages strips provider_config_id from each message.
func SanitizeMessages(msgs []models.Message) []ExportMessage {
out := make([]ExportMessage, len(msgs))
for i := range msgs {
msgs[i].ProviderConfigID = nil
out[i] = ExportMessage{Message: msgs[i]}
}
return out
}
// ── Channel Model ────────────────────────────
// ExportChannelModel strips provider_config_id and persona_id.
type ExportChannelModel struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
ModelID string `json:"model_id"`
Handle string `json:"handle,omitempty"`
DisplayName string `json:"display_name,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
IsDefault bool `json:"is_default"`
}
// SanitizeChannelModels strips instance-specific refs.
func SanitizeChannelModels(cms []models.ChannelModel) []ExportChannelModel {
out := make([]ExportChannelModel, len(cms))
for i, cm := range cms {
out[i] = ExportChannelModel{
ID: cm.ID,
ChannelID: cm.ChannelID,
ModelID: cm.ModelID,
Handle: cm.Handle,
DisplayName: cm.DisplayName,
SystemPrompt: cm.SystemPrompt,
IsDefault: cm.IsDefault,
}
}
return out
}
// ── User Model Settings ──────────────────────
// ExportUserModelSetting strips provider_config_id.
type ExportUserModelSetting struct {
models.UserModelSetting
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
}
// SanitizeUserModelSettings strips instance-specific provider binding.
func SanitizeUserModelSettings(ums []models.UserModelSetting) []ExportUserModelSetting {
out := make([]ExportUserModelSetting, len(ums))
for i := range ums {
ums[i].ProviderConfigID = nil
out[i] = ExportUserModelSetting{UserModelSetting: ums[i]}
}
return out
}
// ── Usage Entry ──────────────────────────────
// ExportUsageEntry strips provider_config_id and routing_decision.
type ExportUsageEntry struct {
ID string `json:"id"`
ChannelID *string `json:"channel_id,omitempty"`
UserID string `json:"user_id"`
ModelID string `json:"model_id"`
Role *string `json:"role,omitempty"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
CostInput *float64 `json:"cost_input,omitempty"`
CostOutput *float64 `json:"cost_output,omitempty"`
CreatedAt string `json:"created_at"`
}
// SanitizeUsageEntries strips instance-specific fields.
func SanitizeUsageEntries(ues []models.UsageEntry) []ExportUsageEntry {
out := make([]ExportUsageEntry, len(ues))
for i, ue := range ues {
out[i] = ExportUsageEntry{
ID: ue.ID,
ChannelID: ue.ChannelID,
UserID: ue.UserID,
ModelID: ue.ModelID,
Role: ue.Role,
PromptTokens: ue.PromptTokens,
CompletionTokens: ue.CompletionTokens,
CacheCreationTokens: ue.CacheCreationTokens,
CacheReadTokens: ue.CacheReadTokens,
CostInput: ue.CostInput,
CostOutput: ue.CostOutput,
CreatedAt: ue.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
return out
}
// ── Workspace ────────────────────────────────
// ExportWorkspace strips root_path (json:"-" already) and git credentials.
type ExportWorkspace struct {
models.Workspace
GitCredentialID *string `json:"git_credential_id,omitempty"` // always nil
}
// SanitizeWorkspaces strips git credential refs.
func SanitizeWorkspaces(ws []models.Workspace) []ExportWorkspace {
out := make([]ExportWorkspace, len(ws))
for i := range ws {
ws[i].GitCredentialID = nil
ws[i].GitLastSync = nil
out[i] = ExportWorkspace{Workspace: ws[i]}
}
return out
}
// ── Persona ──────────────────────────────────
// ExportPersona strips provider_config_id.
type ExportPersona struct {
models.Persona
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
}
// SanitizePersonas strips instance-specific provider binding.
func SanitizePersonas(ps []models.Persona) []ExportPersona {
out := make([]ExportPersona, len(ps))
for i := range ps {
ps[i].ProviderConfigID = nil
out[i] = ExportPersona{Persona: ps[i]}
}
return out
}
// ── Workflow ─────────────────────────────────
// SanitizeWorkflows strips webhook secrets.
func SanitizeWorkflows(wfs []models.Workflow) []models.Workflow {
out := make([]models.Workflow, len(wfs))
for i := range wfs {
out[i] = wfs[i]
out[i].WebhookSecret = ""
}
return out
}
// Note links use models.ExportNoteLink (includes source_note_id).
// See models/models.go for the type definition.
// Note, Memory, Folder, NotificationPreference,
// ChannelParticipant, ChannelCursor, Project, ProjectChannel,
// ProjectKB, ProjectNote, WorkspaceFile, PersonaGroup,
// PersonaGroupMember, KnowledgeBase, KBDocument, Group,
// GroupMember, ResourceGrant, Team, TeamMember, WorkflowVersion,
// WorkflowStage — these are exported as-is (no sensitive fields
// after query-level exclusion of embeddings).

View File

@@ -1,211 +0,0 @@
package export
// format.go — v0.34.0 CS0
//
// Defines the .switchboard archive format: a zip file containing a
// manifest.json and per-entity JSON files under data/. File blobs
// live under files/{id}/{filename}.
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"time"
)
// ── Constants ────────────────────────────────
const (
FormatVersion = 1
MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total
MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob
MaxExportFiles = 10000
ExportContentType = "application/zip"
ExportExtension = ".switchboard"
)
// ── Manifest ─────────────────────────────────
// Manifest is the top-level metadata written to manifest.json.
type Manifest struct {
Version string `json:"version"` // server version, e.g. "0.34.0"
FormatVersion int `json:"format_version"` // always 1 for now
ExportType string `json:"export_type"` // "user", "team"
CreatedAt time.Time `json:"created_at"`
ExportedBy string `json:"exported_by"` // user ID
EntityCounts map[string]int `json:"entity_counts"`
Scope *ManifestScope `json:"scope,omitempty"`
}
// ManifestScope narrows the export to a user or team.
type ManifestScope struct {
UserID string `json:"user_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
}
// ── ArchiveWriter ────────────────────────────
// ArchiveWriter wraps zip.Writer with size tracking and convenience
// methods for writing manifest and entity JSON files.
type ArchiveWriter struct {
zw *zip.Writer
written int64
maxBytes int64
}
// NewArchiveWriter creates an ArchiveWriter that streams to w.
func NewArchiveWriter(w io.Writer) *ArchiveWriter {
return &ArchiveWriter{
zw: zip.NewWriter(w),
maxBytes: MaxExportArchiveSize,
}
}
// WriteManifest serializes m as indented JSON into manifest.json.
func (aw *ArchiveWriter) WriteManifest(m *Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("export: marshal manifest: %w", err)
}
return aw.writeEntry("manifest.json", data)
}
// WriteEntityJSON serializes data as indented JSON into data/{name}.json.
func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) {
buf, err := json.MarshalIndent(data, "", " ")
if err != nil {
return 0, fmt.Errorf("export: marshal %s: %w", name, err)
}
if err := aw.writeEntry("data/"+name+".json", buf); err != nil {
return 0, err
}
// Return entity count for manifest
switch v := data.(type) {
case []interface{}:
return len(v), nil
default:
// Use json.RawMessage length heuristic — caller tracks count
return 0, nil
}
}
// WriteFile streams a file blob into files/{zipPath}. Returns bytes
// written or an error if the file exceeds MaxExportFileSize.
func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) {
if aw.written+1 > aw.maxBytes {
return 0, fmt.Errorf("export: archive size limit exceeded")
}
f, err := aw.zw.Create("files/" + zipPath)
if err != nil {
return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err)
}
lr := io.LimitReader(r, MaxExportFileSize+1)
n, err := io.Copy(f, lr)
if err != nil {
return n, fmt.Errorf("export: write file %s: %w", zipPath, err)
}
if n > MaxExportFileSize {
return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize)
}
aw.written += n
return n, nil
}
// Close finalizes the zip archive.
func (aw *ArchiveWriter) Close() error {
return aw.zw.Close()
}
// BytesWritten returns the approximate bytes written so far.
func (aw *ArchiveWriter) BytesWritten() int64 {
return aw.written
}
// ── ArchiveReader ────────────────────────────
// ArchiveReader wraps zip.ReadCloser with validation helpers.
type ArchiveReader struct {
zr *zip.ReadCloser
}
// OpenArchive opens and validates a .switchboard zip archive.
func OpenArchive(path string) (*ArchiveReader, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return nil, fmt.Errorf("export: open archive: %w", err)
}
return &ArchiveReader{zr: zr}, nil
}
// ReadManifest reads and validates manifest.json from the archive.
func (ar *ArchiveReader) ReadManifest() (*Manifest, error) {
for _, f := range ar.zr.File {
if f.Name == "manifest.json" {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("export: open manifest: %w", err)
}
defer rc.Close()
var m Manifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return nil, fmt.Errorf("export: decode manifest: %w", err)
}
if m.FormatVersion > FormatVersion {
return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion)
}
return &m, nil
}
}
return nil, fmt.Errorf("export: manifest.json not found in archive")
}
// ReadEntityJSON reads data/{name}.json into target.
func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error {
path := "data/" + name + ".json"
for _, f := range ar.zr.File {
if f.Name == path {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("export: open %s: %w", path, err)
}
defer rc.Close()
if err := json.NewDecoder(rc).Decode(target); err != nil {
return fmt.Errorf("export: decode %s: %w", path, err)
}
return nil
}
}
// Entity file not present — not an error (sparse archives are valid)
return nil
}
// FileEntries returns zip entries under files/.
func (ar *ArchiveReader) FileEntries() []*zip.File {
var out []*zip.File
for _, f := range ar.zr.File {
if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() {
out = append(out, f)
}
}
return out
}
// Close closes the underlying zip reader.
func (ar *ArchiveReader) Close() error {
return ar.zr.Close()
}
// writeEntry writes raw bytes as a zip entry, tracking total size.
func (aw *ArchiveWriter) writeEntry(name string, data []byte) error {
if aw.written+int64(len(data)) > aw.maxBytes {
return fmt.Errorf("export: archive size limit exceeded writing %s", name)
}
f, err := aw.zw.Create(name)
if err != nil {
return fmt.Errorf("export: create entry %s: %w", name, err)
}
n, err := f.Write(data)
aw.written += int64(n)
return err
}

View File

@@ -1,294 +0,0 @@
package extraction
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
)
// ── Status Constants ───────────────────────
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusComplete = "complete"
StatusFailed = "failed"
StatusSkipped = "skipped" // not extractable (images, etc.)
)
// ── Queue Item ─────────────────────────────
// QueueItem is the status.json written to the processing directory.
// The sidecar extractor watches this directory for pending items.
type QueueItem struct {
FileID string `json:"file_id"`
StorageKey string `json:"storage_key"`
ContentType string `json:"content_type"`
Filename string `json:"filename"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
OutputKey string `json:"output_key,omitempty"` // path to extracted text
QueuedAt string `json:"queued_at"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
}
// ── Queue Manager ──────────────────────────
// Queue manages the filesystem-based extraction queue.
// Processing state lives in {storagePath}/processing/{file_id}/status.json.
// The sidecar extractor watches for pending items and updates status.
type Queue struct {
storagePath string
concurrency int
sem chan struct{} // semaphore for concurrent extraction limit
mu sync.Mutex
}
// NewQueue creates a new extraction queue manager.
// storagePath is the PVC mount (e.g., /data/storage).
// concurrency limits parallel extractions (default 3).
func NewQueue(storagePath string, concurrency int) (*Queue, error) {
if concurrency <= 0 {
concurrency = 3
}
procDir := filepath.Join(storagePath, "processing")
if err := os.MkdirAll(procDir, 0750); err != nil {
return nil, fmt.Errorf("extraction: create processing dir: %w", err)
}
return &Queue{
storagePath: storagePath,
concurrency: concurrency,
sem: make(chan struct{}, concurrency),
}, nil
}
// Enqueue adds a file to the extraction queue.
// Creates {storagePath}/processing/{id}/status.json with status "pending".
func (q *Queue) Enqueue(fileID, storageKey, contentType, filename string) error {
q.mu.Lock()
defer q.mu.Unlock()
itemDir := filepath.Join(q.storagePath, "processing", fileID)
if err := os.MkdirAll(itemDir, 0750); err != nil {
return fmt.Errorf("extraction: create item dir: %w", err)
}
item := QueueItem{
FileID: fileID,
StorageKey: storageKey,
ContentType: contentType,
Filename: filename,
Status: StatusPending,
QueuedAt: time.Now().UTC().Format(time.RFC3339),
}
return q.writeStatus(fileID, &item)
}
// GetStatus reads the current extraction status for a file.
func (q *Queue) GetStatus(fileID string) (*QueueItem, error) {
statusPath := filepath.Join(q.storagePath, "processing", fileID, "status.json")
data, err := os.ReadFile(statusPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // not queued
}
return nil, fmt.Errorf("extraction: read status: %w", err)
}
var item QueueItem
if err := json.Unmarshal(data, &item); err != nil {
return nil, fmt.Errorf("extraction: parse status: %w", err)
}
return &item, nil
}
// MarkComplete updates status to complete and records the output key.
func (q *Queue) MarkComplete(fileID, outputKey string) error {
q.mu.Lock()
defer q.mu.Unlock()
item, err := q.GetStatus(fileID)
if err != nil || item == nil {
return fmt.Errorf("extraction: item not found: %s", fileID)
}
item.Status = StatusComplete
item.OutputKey = outputKey
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return q.writeStatus(fileID, item)
}
// MarkFailed updates status to failed with an error message.
func (q *Queue) MarkFailed(fileID, errMsg string) error {
q.mu.Lock()
defer q.mu.Unlock()
item, err := q.GetStatus(fileID)
if err != nil || item == nil {
return fmt.Errorf("extraction: item not found: %s", fileID)
}
item.Status = StatusFailed
item.Error = errMsg
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
return q.writeStatus(fileID, item)
}
// Cleanup removes the processing directory for a completed/failed item.
func (q *Queue) Cleanup(fileID string) error {
itemDir := filepath.Join(q.storagePath, "processing", fileID)
return os.RemoveAll(itemDir)
}
// RecoverStale scans for items stuck in "processing" state (crash recovery).
// Items older than maxAge are reset to "pending" for re-processing.
func (q *Queue) RecoverStale(maxAge time.Duration) (int, error) {
procDir := filepath.Join(q.storagePath, "processing")
entries, err := os.ReadDir(procDir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
recovered := 0
cutoff := time.Now().Add(-maxAge)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
item, err := q.GetStatus(entry.Name())
if err != nil || item == nil {
continue
}
if item.Status != StatusProcessing {
continue
}
// Check if started_at is older than maxAge
if item.StartedAt != "" {
startedAt, err := time.Parse(time.RFC3339, item.StartedAt)
if err == nil && startedAt.Before(cutoff) {
q.mu.Lock()
item.Status = StatusPending
item.StartedAt = ""
item.Error = "recovered from stale processing state"
q.writeStatus(entry.Name(), item)
q.mu.Unlock()
recovered++
log.Printf("extraction: recovered stale item %s", entry.Name())
}
}
}
return recovered, nil
}
// ListPending returns all items with status "pending".
func (q *Queue) ListPending() ([]QueueItem, error) {
return q.listByStatus(StatusPending)
}
// ListAll returns all items in the processing directory.
func (q *Queue) ListAll() ([]QueueItem, error) {
procDir := filepath.Join(q.storagePath, "processing")
entries, err := os.ReadDir(procDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var items []QueueItem
for _, entry := range entries {
if !entry.IsDir() {
continue
}
item, err := q.GetStatus(entry.Name())
if err != nil || item == nil {
continue
}
items = append(items, *item)
}
return items, nil
}
// ── Internal Helpers ───────────────────────
func (q *Queue) writeStatus(fileID string, item *QueueItem) error {
statusPath := filepath.Join(q.storagePath, "processing", fileID, "status.json")
data, err := json.MarshalIndent(item, "", " ")
if err != nil {
return err
}
// Atomic write: temp + rename
tmpPath := statusPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0640); err != nil {
return err
}
return os.Rename(tmpPath, statusPath)
}
func (q *Queue) listByStatus(status string) ([]QueueItem, error) {
all, err := q.ListAll()
if err != nil {
return nil, err
}
var filtered []QueueItem
for _, item := range all {
if item.Status == status {
filtered = append(filtered, item)
}
}
return filtered, nil
}
// ── Extractable Check ──────────────────────
// IsExtractable returns true if the content type supports text extraction.
// Images and plain text do not need extraction (handled inline by upload handler).
var extractableTypes = map[string]bool{
"application/pdf": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/msword": true,
"application/vnd.ms-excel": true,
"application/vnd.oasis.opendocument.text": true,
"application/vnd.oasis.opendocument.spreadsheet": true,
"application/vnd.oasis.opendocument.presentation": true,
"application/rtf": true,
}
// IsExtractable returns true if the given content type requires sidecar extraction.
func IsExtractable(contentType string) bool {
return extractableTypes[contentType]
}
// ── Null Queue ─────────────────────────────
// NullQueue is a no-op implementation for when extraction is disabled.
type NullQueue struct{}
func (NullQueue) Enqueue(_, _, _, _ string) error { return nil }
func (NullQueue) GetStatus(_ string) (*QueueItem, error) { return nil, nil }
func (NullQueue) MarkComplete(_, _ string) error { return nil }
func (NullQueue) MarkFailed(_, _ string) error { return nil }
func (NullQueue) Cleanup(_ string) error { return nil }
func (NullQueue) RecoverStale(_ time.Duration) (int, error) { return 0, nil }
func (NullQueue) ListPending() ([]QueueItem, error) { return nil, nil }
func (NullQueue) ListAll() ([]QueueItem, error) { return nil, nil }

View File

@@ -1,240 +0,0 @@
package extraction
import (
"os"
"path/filepath"
"testing"
"time"
)
func tempQueue(t *testing.T) *Queue {
t.Helper()
dir := t.TempDir()
q, err := NewQueue(dir, 3)
if err != nil {
t.Fatalf("NewQueue: %v", err)
}
return q
}
func TestEnqueue_CreatesStatusFile(t *testing.T) {
q := tempQueue(t)
err := q.Enqueue("att-123", "files/ch/att-123_doc.pdf", "application/pdf", "doc.pdf")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
item, err := q.GetStatus("att-123")
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
if item == nil {
t.Fatal("expected non-nil item")
}
if item.Status != StatusPending {
t.Errorf("status = %q, want %q", item.Status, StatusPending)
}
if item.FileID != "att-123" {
t.Errorf("file_id = %q, want att-123", item.FileID)
}
if item.ContentType != "application/pdf" {
t.Errorf("content_type = %q, want application/pdf", item.ContentType)
}
}
func TestGetStatus_NotQueued(t *testing.T) {
q := tempQueue(t)
item, err := q.GetStatus("nonexistent")
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
if item != nil {
t.Error("expected nil for non-queued item")
}
}
func TestMarkComplete(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-456", "key", "application/pdf", "test.pdf")
err := q.MarkComplete("att-456", "processing/att-456/extracted.txt")
if err != nil {
t.Fatalf("MarkComplete: %v", err)
}
item, _ := q.GetStatus("att-456")
if item.Status != StatusComplete {
t.Errorf("status = %q, want %q", item.Status, StatusComplete)
}
if item.OutputKey != "processing/att-456/extracted.txt" {
t.Errorf("output_key = %q", item.OutputKey)
}
if item.CompletedAt == "" {
t.Error("completed_at should be set")
}
}
func TestMarkFailed(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-789", "key", "application/pdf", "test.pdf")
err := q.MarkFailed("att-789", "libreoffice crashed")
if err != nil {
t.Fatalf("MarkFailed: %v", err)
}
item, _ := q.GetStatus("att-789")
if item.Status != StatusFailed {
t.Errorf("status = %q, want %q", item.Status, StatusFailed)
}
if item.Error != "libreoffice crashed" {
t.Errorf("error = %q", item.Error)
}
}
func TestCleanup_RemovesDirectory(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-cleanup", "key", "application/pdf", "test.pdf")
// Verify directory exists
dir := filepath.Join(q.storagePath, "processing", "att-cleanup")
if _, err := os.Stat(dir); err != nil {
t.Fatal("expected processing dir to exist before cleanup")
}
if err := q.Cleanup("att-cleanup"); err != nil {
t.Fatalf("Cleanup: %v", err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("expected processing dir to be removed after cleanup")
}
}
func TestListPending(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-a", "key-a", "application/pdf", "a.pdf")
q.Enqueue("att-b", "key-b", "application/pdf", "b.pdf")
q.MarkComplete("att-b", "out")
pending, err := q.ListPending()
if err != nil {
t.Fatalf("ListPending: %v", err)
}
if len(pending) != 1 {
t.Fatalf("expected 1 pending, got %d", len(pending))
}
if pending[0].FileID != "att-a" {
t.Errorf("pending[0].FileID = %q, want att-a", pending[0].FileID)
}
}
func TestListAll(t *testing.T) {
q := tempQueue(t)
q.Enqueue("att-1", "key-1", "application/pdf", "1.pdf")
q.Enqueue("att-2", "key-2", "application/pdf", "2.pdf")
q.Enqueue("att-3", "key-3", "application/pdf", "3.pdf")
all, err := q.ListAll()
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(all) != 3 {
t.Errorf("expected 3 items, got %d", len(all))
}
}
func TestRecoverStale(t *testing.T) {
q := tempQueue(t)
// Create item and manually set it to "processing" with old timestamp
q.Enqueue("att-stale", "key", "application/pdf", "stale.pdf")
item, _ := q.GetStatus("att-stale")
item.Status = StatusProcessing
item.StartedAt = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339)
q.mu.Lock()
q.writeStatus("att-stale", item)
q.mu.Unlock()
// Recover items stale for > 1 hour
recovered, err := q.RecoverStale(1 * time.Hour)
if err != nil {
t.Fatalf("RecoverStale: %v", err)
}
if recovered != 1 {
t.Errorf("recovered = %d, want 1", recovered)
}
// Verify it's back to pending
item, _ = q.GetStatus("att-stale")
if item.Status != StatusPending {
t.Errorf("status = %q, want %q", item.Status, StatusPending)
}
}
func TestRecoverStale_SkipsRecent(t *testing.T) {
q := tempQueue(t)
// Create item "processing" but recent (not stale)
q.Enqueue("att-recent", "key", "application/pdf", "recent.pdf")
item, _ := q.GetStatus("att-recent")
item.Status = StatusProcessing
item.StartedAt = time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339)
q.mu.Lock()
q.writeStatus("att-recent", item)
q.mu.Unlock()
recovered, err := q.RecoverStale(1 * time.Hour)
if err != nil {
t.Fatalf("RecoverStale: %v", err)
}
if recovered != 0 {
t.Errorf("recovered = %d, want 0 (item is recent)", recovered)
}
}
func TestIsExtractable(t *testing.T) {
tests := []struct {
contentType string
want bool
}{
{"application/pdf", true},
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", true},
{"image/png", false},
{"text/plain", false},
{"image/jpeg", false},
{"application/rtf", true},
}
for _, tc := range tests {
got := IsExtractable(tc.contentType)
if got != tc.want {
t.Errorf("IsExtractable(%q) = %v, want %v", tc.contentType, got, tc.want)
}
}
}
func TestAtomicWrite(t *testing.T) {
q := tempQueue(t)
// Enqueue and verify the file is valid JSON
q.Enqueue("att-atomic", "key", "application/pdf", "test.pdf")
statusPath := filepath.Join(q.storagePath, "processing", "att-atomic", "status.json")
data, err := os.ReadFile(statusPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
// Verify no .tmp file left behind
tmpPath := statusPath + ".tmp"
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Error("temp file should not exist after atomic write")
}
if len(data) == 0 {
t.Error("status.json should not be empty")
}
}

View File

@@ -1,83 +0,0 @@
// Package filters — discover.go
//
// v0.29.0 CS3: Discovers active Starlark packages with
// filters.pre_completion permission and registers them in the chain.
// Called at startup and after package install/permission changes.
package filters
import (
"context"
"log"
"switchboard-core/models"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// DiscoverStarlarkFilters scans the package registry for active starlark
// extensions with granted filters.pre_completion permission and registers
// them in the filter chain. Extension filters start at order 100.
//
// Called once at startup. Future: also called on package install/activate
// to hot-register new filters without restart.
func DiscoverStarlarkFilters(ctx context.Context, chain *Chain, stores store.Stores, runner *sandbox.Runner) {
if stores.ExtPermissions == nil || stores.Packages == nil {
return
}
pkgs, err := stores.Packages.ListEnabledByType(ctx, "extension")
if err != nil {
log.Printf("⚠ filter discovery: failed to list extensions: %v", err)
return
}
// Also check "full" packages (surface + extension)
fullPkgs, err := stores.Packages.ListEnabledByType(ctx, "full")
if err == nil {
pkgs = append(pkgs, fullPkgs...)
}
registered := 0
for i := range pkgs {
pkg := &pkgs[i]
// Must be starlark tier and active
if pkg.Tier != models.ExtTierStarlark {
continue
}
if pkg.Status != models.PackageStatusActive {
continue
}
// Must have the pre_completion filter permission granted
granted, err := stores.ExtPermissions.GrantedForPackage(ctx, pkg.ID)
if err != nil {
continue
}
hasFilterPerm := false
for _, p := range granted {
if p == models.ExtPermFiltersPreCompletion {
hasFilterPerm = true
break
}
}
if !hasFilterPerm {
continue
}
// Must have a starlark script
if _, ok := pkg.Manifest["_starlark_script"].(string); !ok {
continue
}
// Register with order 100+ (extension range, after built-ins)
order := 100 + registered
chain.Register(NewStarlarkFilter(runner, pkg, order))
registered++
}
if registered > 0 {
log.Printf(" 🔗 Discovered %d Starlark pre-completion filter(s)", registered)
}
}

View File

@@ -1,134 +0,0 @@
// Package filters — pre-completion filter chain.
//
// v0.29.0: Reference implementation for the server-side filter model.
// Filters run before every completion request, injecting context
// (system messages, knowledge base results, external data) into the
// conversation. The chain is composable: Go built-in filters register
// at startup; Starlark extension filters register at package install.
//
// Architecture:
//
// user message → [filter chain] → LLM
// │
// ├─ KB auto-inject (built-in, CS0)
// ├─ memory hint (future migration)
// └─ ext filters (Starlark, CS3)
//
// Each filter receives a CompletionContext and returns zero or more
// system messages to inject. Filters are executed in registration
// order. A filter failure is logged and skipped — it never aborts
// the completion.
package filters
import (
"context"
"log"
"sort"
"time"
)
// ─── Context ─────────────────────────────────
// CompletionContext carries the state available to pre-completion filters.
// Filters should treat this as read-only.
type CompletionContext struct {
ChannelID string
UserID string
PersonaID string
LastUserMessage string // current user message content (for semantic search)
TeamIDs []string
}
// ─── Contribution ────────────────────────────
// InjectedMessage is a role + content pair that a filter wants to inject
// into the conversation. Kept simple to avoid coupling to provider types.
type InjectedMessage struct {
Role string // "system" (most common), "user", or "assistant"
Content string
}
// Contribution holds what a filter wants to inject into the completion.
type Contribution struct {
// Messages are injected into the conversation in order, after the
// persona/project system prompts and before the message history.
Messages []InjectedMessage
}
// ─── Filter Interface ────────────────────────
// PreCompletionFilter processes context before a completion request.
// Implementations must be safe for concurrent use.
type PreCompletionFilter interface {
// Name returns a human-readable identifier (e.g., "kb-auto-inject").
// Used in logs and admin UI.
Name() string
// Order returns the execution priority. Lower values run first.
// Built-in filters use 099; extension filters use 100+.
Order() int
// Execute runs the filter and returns messages to inject.
// Returning nil or an empty Contribution is valid (no injection).
// Errors are logged and skipped — they never abort the completion.
Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error)
}
// ─── Chain ───────────────────────────────────
// Chain holds an ordered set of pre-completion filters.
type Chain struct {
filters []PreCompletionFilter
sorted bool
}
// NewChain creates an empty filter chain.
func NewChain() *Chain {
return &Chain{}
}
// Register adds a filter to the chain. Filters are sorted by Order()
// before the first execution.
func (c *Chain) Register(f PreCompletionFilter) {
c.filters = append(c.filters, f)
c.sorted = false
log.Printf(" 🔗 Pre-completion filter registered: %s (order=%d)", f.Name(), f.Order())
}
// Len returns the number of registered filters.
func (c *Chain) Len() int {
return len(c.filters)
}
// Execute runs all filters in order, collecting their contributions.
// Returns the combined list of messages to inject. Individual filter
// errors are logged and skipped.
func (c *Chain) Execute(ctx context.Context, cc *CompletionContext) []InjectedMessage {
if len(c.filters) == 0 {
return nil
}
if !c.sorted {
sort.Slice(c.filters, func(i, j int) bool {
return c.filters[i].Order() < c.filters[j].Order()
})
c.sorted = true
}
var messages []InjectedMessage
for _, f := range c.filters {
start := time.Now()
contrib, err := f.Execute(ctx, cc)
elapsed := time.Since(start)
if err != nil {
log.Printf("⚠ filter %s failed (%.0fms): %v", f.Name(), elapsed.Seconds()*1000, err)
continue
}
if contrib != nil && len(contrib.Messages) > 0 {
messages = append(messages, contrib.Messages...)
log.Printf(" 🔗 filter %s: injected %d messages (%.0fms)", f.Name(), len(contrib.Messages), elapsed.Seconds()*1000)
}
}
return messages
}

View File

@@ -1,155 +0,0 @@
package filters
import (
"context"
"errors"
"testing"
)
// ── Mock filter ─────────────────────────────
type mockFilter struct {
name string
order int
messages []InjectedMessage
err error
}
func (m *mockFilter) Name() string { return m.name }
func (m *mockFilter) Order() int { return m.order }
func (m *mockFilter) Execute(_ context.Context, _ *CompletionContext) (*Contribution, error) {
if m.err != nil {
return nil, m.err
}
if len(m.messages) == 0 {
return nil, nil
}
return &Contribution{Messages: m.messages}, nil
}
// ── Tests ───────────────────────────────────
func TestChainEmpty(t *testing.T) {
c := NewChain()
result := c.Execute(context.Background(), &CompletionContext{})
if len(result) != 0 {
t.Fatalf("expected 0 messages, got %d", len(result))
}
}
func TestChainSingleFilter(t *testing.T) {
c := NewChain()
c.Register(&mockFilter{
name: "test",
order: 0,
messages: []InjectedMessage{
{Role: "system", Content: "hello"},
},
})
result := c.Execute(context.Background(), &CompletionContext{
ChannelID: "ch-1",
UserID: "u-1",
})
if len(result) != 1 {
t.Fatalf("expected 1 message, got %d", len(result))
}
if result[0].Content != "hello" {
t.Fatalf("expected 'hello', got %q", result[0].Content)
}
}
func TestChainOrderRespected(t *testing.T) {
c := NewChain()
// Register out of order — chain should sort by Order()
c.Register(&mockFilter{
name: "second",
order: 20,
messages: []InjectedMessage{{Role: "system", Content: "B"}},
})
c.Register(&mockFilter{
name: "first",
order: 10,
messages: []InjectedMessage{{Role: "system", Content: "A"}},
})
result := c.Execute(context.Background(), &CompletionContext{})
if len(result) != 2 {
t.Fatalf("expected 2 messages, got %d", len(result))
}
if result[0].Content != "A" {
t.Fatalf("expected 'A' first, got %q", result[0].Content)
}
if result[1].Content != "B" {
t.Fatalf("expected 'B' second, got %q", result[1].Content)
}
}
func TestChainErrorIsolation(t *testing.T) {
c := NewChain()
c.Register(&mockFilter{
name: "failing",
order: 0,
err: errors.New("boom"),
})
c.Register(&mockFilter{
name: "surviving",
order: 10,
messages: []InjectedMessage{{Role: "system", Content: "ok"}},
})
result := c.Execute(context.Background(), &CompletionContext{})
if len(result) != 1 {
t.Fatalf("expected 1 message (failing filter skipped), got %d", len(result))
}
if result[0].Content != "ok" {
t.Fatalf("expected 'ok', got %q", result[0].Content)
}
}
func TestChainNilContribution(t *testing.T) {
c := NewChain()
c.Register(&mockFilter{
name: "noop",
order: 0,
// nil messages → nil contribution
})
result := c.Execute(context.Background(), &CompletionContext{})
if len(result) != 0 {
t.Fatalf("expected 0 messages from noop filter, got %d", len(result))
}
}
func TestChainMultipleMessages(t *testing.T) {
c := NewChain()
c.Register(&mockFilter{
name: "multi",
order: 0,
messages: []InjectedMessage{
{Role: "system", Content: "first"},
{Role: "system", Content: "second"},
},
})
result := c.Execute(context.Background(), &CompletionContext{})
if len(result) != 2 {
t.Fatalf("expected 2 messages, got %d", len(result))
}
}
func TestChainLen(t *testing.T) {
c := NewChain()
if c.Len() != 0 {
t.Fatalf("expected 0, got %d", c.Len())
}
c.Register(&mockFilter{name: "a", order: 0})
c.Register(&mockFilter{name: "b", order: 1})
if c.Len() != 2 {
t.Fatalf("expected 2, got %d", c.Len())
}
}

View File

@@ -1,112 +0,0 @@
// Package filters — kb_inject.go
//
// v0.29.0 CS0: Built-in pre-completion filter that auto-injects KB
// context into the system prompt. Refactored from the inline
// BuildKBHint() function in knowledge_bases.go.
//
// This is the reference implementation for the filter chain model
// that Starlark extensions will mirror in CS3.
package filters
import (
"context"
"fmt"
"log"
"switchboard-core/store"
)
// KBInjectFilter injects a system prompt listing active knowledge bases
// so the LLM knows to use the kb_search tool.
type KBInjectFilter struct {
stores store.Stores
}
// NewKBInjectFilter creates the built-in KB auto-inject filter.
func NewKBInjectFilter(stores store.Stores) *KBInjectFilter {
return &KBInjectFilter{stores: stores}
}
func (f *KBInjectFilter) Name() string { return "kb-auto-inject" }
func (f *KBInjectFilter) Order() int { return 10 } // built-in, runs early
func (f *KBInjectFilter) Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) {
type kbInfo struct {
Name string
DocCount int
}
var kbs []kbInfo
seen := make(map[string]bool)
// ── Persona-bound KBs (v0.17.0) ──
if cc.PersonaID != "" {
personaKBs, err := f.stores.Personas.GetKBs(ctx, cc.PersonaID)
if err == nil {
for _, pkb := range personaKBs {
if pkb.ChunkCount > 0 {
kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount})
seen[pkb.KBID] = true
}
}
}
}
// ── Project-bound KBs (v0.19.0) ──
if f.stores.Projects != nil {
projID, _ := f.stores.Projects.GetProjectIDForChannel(ctx, cc.ChannelID)
if projID != "" {
projKBIDs, projErr := f.stores.Projects.GetKBIDs(ctx, projID)
if projErr == nil {
for _, kbID := range projKBIDs {
if !seen[kbID] {
kb, kbErr := f.stores.KnowledgeBases.GetByID(ctx, kbID)
if kbErr == nil && kb.ChunkCount > 0 {
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
seen[kbID] = true
}
}
}
}
}
}
// ── Channel-linked KBs ──
channelKBs, err := f.stores.KnowledgeBases.GetChannelKBs(ctx, cc.ChannelID)
if err == nil {
for _, ckb := range channelKBs {
if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] {
kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
seen[ckb.KBID] = true
}
}
}
// ── Personal KBs (always available to owner) ──
personalKBs, err := f.stores.KnowledgeBases.ListPersonal(ctx, cc.UserID)
if err == nil {
for _, kb := range personalKBs {
if !seen[kb.ID] && kb.ChunkCount > 0 {
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
seen[kb.ID] = true
}
}
}
if len(kbs) == 0 {
return nil, nil
}
hint := "\nYou have access to the following knowledge bases:\n"
for _, kb := range kbs {
hint += fmt.Sprintf("- \"%s\" (%d documents)\n", kb.Name, kb.DocCount)
}
hint += "Use the kb_search tool to find relevant information from these sources when the user asks questions that might be answered by their documents."
log.Printf(" 🔗 kb-auto-inject: %d KBs active for channel %s", len(kbs), cc.ChannelID[:min(8, len(cc.ChannelID))])
return &Contribution{
Messages: []InjectedMessage{
{Role: "system", Content: hint},
},
}, nil
}

View File

@@ -1,133 +0,0 @@
// Package filters — starlark_filter.go
//
// v0.29.0 CS3: Bridges the pre-completion filter chain to Starlark
// extension scripts. When a package declares "filters.pre_completion"
// permission and is active, this filter runs the package's
// on_pre_completion(ctx) function and converts the return value
// into injected messages.
//
// Starlark contract:
//
// def on_pre_completion(ctx):
// """Called before each completion request.
// ctx is a dict: {"channel_id": "...", "user_id": "...", "persona_id": "..."}
// Return a list of dicts: [{"role": "system", "content": "..."}]
// Return None or [] to inject nothing.
// """
// return [{"role": "system", "content": "Extra context from extension"}]
package filters
import (
"context"
"fmt"
"log"
"go.starlark.net/starlark"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// StarlarkFilter runs a Starlark package's on_pre_completion entry point.
type StarlarkFilter struct {
runner *sandbox.Runner
pkg *store.PackageRegistration
order int
}
// NewStarlarkFilter creates a filter backed by a Starlark package.
func NewStarlarkFilter(runner *sandbox.Runner, pkg *store.PackageRegistration, order int) *StarlarkFilter {
return &StarlarkFilter{
runner: runner,
pkg: pkg,
order: order,
}
}
func (f *StarlarkFilter) Name() string { return "ext:" + f.pkg.ID }
func (f *StarlarkFilter) Order() int { return f.order }
func (f *StarlarkFilter) Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) {
// Build context dict for the Starlark function
ctxDict := starlark.NewDict(4)
ctxDict.SetKey(starlark.String("channel_id"), starlark.String(cc.ChannelID))
ctxDict.SetKey(starlark.String("user_id"), starlark.String(cc.UserID))
ctxDict.SetKey(starlark.String("persona_id"), starlark.String(cc.PersonaID))
ctxDict.SetKey(starlark.String("last_user_message"), starlark.String(cc.LastUserMessage))
val, output, err := f.runner.CallEntryPoint(ctx, f.pkg, "on_pre_completion",
starlark.Tuple{ctxDict}, nil, nil)
if err != nil {
return nil, fmt.Errorf("ext:%s: %w", f.pkg.ID, err)
}
if output != "" {
log.Printf(" 🔧 ext:%s print: %s", f.pkg.ID, output)
}
// Parse return value into messages
messages, err := parseStarlarkMessages(val)
if err != nil {
return nil, fmt.Errorf("ext:%s: invalid return value: %w", f.pkg.ID, err)
}
if len(messages) == 0 {
return nil, nil
}
return &Contribution{Messages: messages}, nil
}
// parseStarlarkMessages converts a Starlark return value to InjectedMessages.
// Accepts: None, empty list, or list of dicts with "role" and "content" keys.
func parseStarlarkMessages(val starlark.Value) ([]InjectedMessage, error) {
if val == nil || val == starlark.None {
return nil, nil
}
list, ok := val.(*starlark.List)
if !ok {
return nil, fmt.Errorf("expected list or None, got %s", val.Type())
}
if list.Len() == 0 {
return nil, nil
}
var messages []InjectedMessage
iter := list.Iterate()
defer iter.Done()
var item starlark.Value
for iter.Next(&item) {
dict, ok := item.(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("expected dict in list, got %s", item.Type())
}
roleVal, found, _ := dict.Get(starlark.String("role"))
if !found {
return nil, fmt.Errorf("message dict missing 'role' key")
}
role, ok := starlark.AsString(roleVal)
if !ok {
return nil, fmt.Errorf("'role' must be a string")
}
contentVal, found, _ := dict.Get(starlark.String("content"))
if !found {
return nil, fmt.Errorf("message dict missing 'content' key")
}
content, ok := starlark.AsString(contentVal)
if !ok {
return nil, fmt.Errorf("'content' must be a string")
}
messages = append(messages, InjectedMessage{
Role: role,
Content: content,
})
}
return messages, nil
}

View File

@@ -1,119 +0,0 @@
package filters
import (
"testing"
"go.starlark.net/starlark"
)
func TestParseStarlarkMessages_None(t *testing.T) {
msgs, err := parseStarlarkMessages(starlark.None)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msgs != nil {
t.Fatalf("expected nil, got %d messages", len(msgs))
}
}
func TestParseStarlarkMessages_Nil(t *testing.T) {
msgs, err := parseStarlarkMessages(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msgs != nil {
t.Fatalf("expected nil, got %d messages", len(msgs))
}
}
func TestParseStarlarkMessages_EmptyList(t *testing.T) {
msgs, err := parseStarlarkMessages(starlark.NewList(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msgs != nil {
t.Fatalf("expected nil, got %d messages", len(msgs))
}
}
func TestParseStarlarkMessages_SingleMessage(t *testing.T) {
d := starlark.NewDict(2)
d.SetKey(starlark.String("role"), starlark.String("system"))
d.SetKey(starlark.String("content"), starlark.String("injected context"))
list := starlark.NewList([]starlark.Value{d})
msgs, err := parseStarlarkMessages(list)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Role != "system" {
t.Fatalf("expected role 'system', got %q", msgs[0].Role)
}
if msgs[0].Content != "injected context" {
t.Fatalf("expected content 'injected context', got %q", msgs[0].Content)
}
}
func TestParseStarlarkMessages_MultipleMessages(t *testing.T) {
d1 := starlark.NewDict(2)
d1.SetKey(starlark.String("role"), starlark.String("system"))
d1.SetKey(starlark.String("content"), starlark.String("first"))
d2 := starlark.NewDict(2)
d2.SetKey(starlark.String("role"), starlark.String("system"))
d2.SetKey(starlark.String("content"), starlark.String("second"))
list := starlark.NewList([]starlark.Value{d1, d2})
msgs, err := parseStarlarkMessages(list)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0].Content != "first" || msgs[1].Content != "second" {
t.Fatalf("wrong content: %q, %q", msgs[0].Content, msgs[1].Content)
}
}
func TestParseStarlarkMessages_MissingRole(t *testing.T) {
d := starlark.NewDict(1)
d.SetKey(starlark.String("content"), starlark.String("no role"))
list := starlark.NewList([]starlark.Value{d})
_, err := parseStarlarkMessages(list)
if err == nil {
t.Fatal("expected error for missing role")
}
}
func TestParseStarlarkMessages_MissingContent(t *testing.T) {
d := starlark.NewDict(1)
d.SetKey(starlark.String("role"), starlark.String("system"))
list := starlark.NewList([]starlark.Value{d})
_, err := parseStarlarkMessages(list)
if err == nil {
t.Fatal("expected error for missing content")
}
}
func TestParseStarlarkMessages_WrongType(t *testing.T) {
_, err := parseStarlarkMessages(starlark.String("not a list"))
if err == nil {
t.Fatal("expected error for wrong type")
}
}
func TestParseStarlarkMessages_NonDictInList(t *testing.T) {
list := starlark.NewList([]starlark.Value{starlark.String("not a dict")})
_, err := parseStarlarkMessages(list)
if err == nil {
t.Fatal("expected error for non-dict in list")
}
}

View File

@@ -1,297 +0,0 @@
package handlers
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
)
// ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s, vault: vault}
}
// ListConfigs returns configs accessible to the user (global + personal + team).
func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
userID := getUserID(c)
// User settings → Providers shows only personal (BYOK) configs.
// Global/team providers are managed by admins and surfaced via the model list.
cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
// Mask API keys
type safeConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"`
ModelDefault string `json:"model_default,omitempty"`
Scope string `json:"scope"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
out := make([]safeConfig, len(cfgs))
for i, cfg := range cfgs {
out[i] = safeConfig{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.HasKey(),
ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope,
IsActive: cfg.IsActive,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// GetConfig returns a single config by ID (if user has access).
func (h *ProviderConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
c.JSON(http.StatusOK, cfg)
}
// CreateConfig creates a personal provider config (BYOK).
// After creation, automatically fetches models from the provider API and enables them.
func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
userID := getUserID(c)
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
ModelDefault: req.ModelDefault,
Scope: models.ScopePersonal,
KeyScope: models.ScopePersonal,
OwnerID: &userID,
IsActive: true,
}
// Encrypt the API key with user's UEK (personal scope)
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
// Auto-fetch models from the provider API and enable them.
// The user added this key to USE it — don't make them hunt for a fetch button.
resp := gin.H{
"id": cfg.ID,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"scope": cfg.Scope,
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey)
if err != nil {
// Provider created successfully but model fetch failed.
// Return 201 (provider exists) with a warning so the frontend can show it.
resp["warning"] = "Provider created but model fetch failed: " + err.Error()
resp["models_fetched"] = 0
log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err)
} else {
resp["models_fetched"] = result.Total
}
c.JSON(http.StatusCreated, resp)
}
// UpdateConfig updates a personal provider config.
func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"})
return
}
// Bind standard fields
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
// DeleteConfig deletes a personal provider config.
func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"})
return
}
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ListModels returns models for a specific provider config.
func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
id := c.Param("id")
entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"data": entries})
}
// FetchModels fetches models from the provider API and auto-enables them.
// This is the user-facing equivalent of admin POST /models/fetch.
// Allows refreshing models for existing BYOK providers.
func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
// Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
} else {
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
if errors.Is(err, ErrUpstreamTimeout) {
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "models synced",
"added": result.Added,
"updated": result.Updated,
"total": result.Total,
})
}

View File

@@ -1,228 +0,0 @@
package handlers
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"net/http"
"strings"
// Register decoders so image.Decode works for common formats
_ "image/gif"
_ "image/jpeg"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
const avatarSize = 128
const maxUploadBytes = 2 * 1024 * 1024 // 2MB raw input limit
type uploadAvatarRequest struct {
Image string `json:"image" binding:"required"` // base64 data URI or raw base64
}
// ── Upload Avatar ───────────────────────────
// POST /api/v1/profile/avatar
// Accepts { "image": "data:image/png;base64,..." } or { "image": "<raw base64>" }
// Decodes, resizes to 128×128 PNG, stores as data URI.
func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
userID := getUserID(c)
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
// Strip data URI prefix if present
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
// Decode base64
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
// Decode image (supports PNG, JPEG, GIF via registered decoders)
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
// Resize to 128×128
resized := resizeBilinear(src, avatarSize, avatarSize)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
// Build data URI
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
// Store in DB
err = h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// ── Delete Avatar ───────────────────────────
// DELETE /api/v1/profile/avatar
func (h *SettingsHandler) DeleteAvatar(c *gin.Context) {
userID := getUserID(c)
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": nil})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}
// ── Bilinear Resize ─────────────────────────
// Pure stdlib bilinear interpolation. Quality is fine for 128×128 avatars.
func resizeBilinear(src image.Image, w, h int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
sb := src.Bounds()
sw := float64(sb.Dx())
sh := float64(sb.Dy())
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// Map destination pixel to source coordinates
sx := (float64(x) + 0.5) * sw / float64(w) - 0.5
sy := (float64(y) + 0.5) * sh / float64(h) - 0.5
x0 := int(sx)
y0 := int(sy)
xf := sx - float64(x0)
yf := sy - float64(y0)
// Clamp
if x0 < sb.Min.X { x0 = sb.Min.X }
if y0 < sb.Min.Y { y0 = sb.Min.Y }
x1 := x0 + 1
y1 := y0 + 1
if x1 >= sb.Max.X { x1 = sb.Max.X - 1 }
if y1 >= sb.Max.Y { y1 = sb.Max.Y - 1 }
// Sample 4 neighbors
c00 := src.At(x0, y0)
c10 := src.At(x1, y0)
c01 := src.At(x0, y1)
c11 := src.At(x1, y1)
dst.Set(x, y, bilinearMix(c00, c10, c01, c11, xf, yf))
}
}
return dst
}
func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
r00, g00, b00, a00 := c00.RGBA()
r10, g10, b10, a10 := c10.RGBA()
r01, g01, b01, a01 := c01.RGBA()
r11, g11, b11, a11 := c11.RGBA()
mix := func(v00, v10, v01, v11 uint32) uint8 {
top := float64(v00)*(1-xf) + float64(v10)*xf
bot := float64(v01)*(1-xf) + float64(v11)*xf
return uint8((top*(1-yf) + bot*yf) / 256)
}
return color.RGBA{
R: mix(r00, r10, r01, r11),
G: mix(g00, g10, g01, g11),
B: mix(b00, b10, b01, b11),
A: mix(a00, a10, a01, a11),
}
}
// ── Persona Avatar Upload ────────────────────
// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
// UploadPersonaAvatar uploads and resizes a persona avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func UploadPersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
resized := resizeBilinear(src, avatarSize, avatarSize)
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
err = personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// DeletePersonaAvatar clears a persona's avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func DeletePersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
empty := ""
err := personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &empty})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}

View File

@@ -1,142 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/auth"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/store"
)
// ModelHandler provides the unified models endpoint.
type ModelHandler struct {
stores store.Stores
healthStore HealthStatusQuerier // provider health for status enrichment (v0.22.3, nil = disabled)
}
func NewModelHandler(s store.Stores) *ModelHandler {
return &ModelHandler{stores: s}
}
// SetHealthStore attaches the health store for model status enrichment (v0.22.3).
func (h *ModelHandler) SetHealthStore(hs HealthStatusQuerier) {
h.healthStore = hs
}
// ListEnabledModels returns all models the user can access (catalog + personas),
// with user preferences (hidden, sort order) applied.
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID)
if err != nil {
log.Printf("error: ModelsForUser(%s): %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"})
return
}
// Enrich with provider health status (v0.22.3)
if h.healthStore != nil {
healthMap := h.buildHealthMap(c.Request.Context())
for i := range userModels {
if s, ok := healthMap[userModels[i].ProviderConfigID]; ok {
userModels[i].ProviderStatus = s
}
}
}
// Include admin default model so frontend can use it in resolution chain
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
// Model allowlist filtering (v0.24.2) — non-admin users with group-level
// model restrictions only see permitted models. Persona entries whose
// underlying model is disallowed are also filtered.
role, _ := c.Get("role")
if role != "admin" {
allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID)
if err == nil && allowlist != nil {
filtered := make([]models.UserModel, 0, len(userModels))
for _, m := range userModels {
if allowlist[m.ModelID] {
filtered = append(filtered, m)
}
}
userModels = filtered
}
}
c.JSON(http.StatusOK, gin.H{"data": userModels, "default_model": defaultModel})
}
// buildHealthMap returns a map of configID → ProviderStatus from current health windows.
func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.ProviderStatus {
m := make(map[string]models.ProviderStatus)
windows, err := h.healthStore.ListAllCurrent(ctx)
if err != nil {
return m
}
for _, w := range windows {
m[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
}
return m
}
// ResolveModelCaps is the canonical capability resolver for any model.
// v0.29.0: accepts CatalogStore instead of using database.DB directly.
func ResolveModelCaps(catalog store.CatalogStore, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id
if configID != "" {
caps, ok := capsFromCatalog(catalog, modelID, configID)
if ok {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
}
// 2. Any provider: same model_id, any config
caps, ok := capsFromCatalog(catalog, modelID, "")
if ok {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// 3. Heuristic inference
caps = capspkg.InferCapabilities(modelID)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// capsFromCatalog looks up capabilities from the model_catalog table.
func capsFromCatalog(catalog store.CatalogStore, modelID, configID string) (models.ModelCapabilities, bool) {
if catalog == nil {
return models.ModelCapabilities{}, false
}
var capsJSON []byte
var err error
if configID != "" {
capsJSON, err = catalog.GetCapabilities(context.Background(), modelID, configID)
} else {
capsJSON, err = catalog.GetCapabilitiesAny(context.Background(), modelID)
}
if err != nil || len(capsJSON) == 0 {
return models.ModelCapabilities{}, false
}
var caps models.ModelCapabilities
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
return models.ModelCapabilities{}, false
}
// Merge with known data to fill gaps
resolved := capspkg.ResolveIntrinsic(modelID, &caps, nil)
return resolved, true
}

View File

@@ -1,240 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Channel Models Handler ──────────────────
// Manages multi-model assignments per channel (v0.20.0 Phase 2).
// Routes:
// GET /channels/:id/models — list
// POST /channels/:id/models — add
// PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt)
// DELETE /channels/:id/models/:modelId — remove
const maxModelsPerChannel = 5
type ChannelModelHandler struct {
stores store.Stores
}
func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler {
return &ChannelModelHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ChannelModelHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
if roster == nil {
roster = []models.ChannelModel{}
}
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Add ──────────────────────────────────────
type addChannelModelRequest struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID string `json:"provider_config_id"`
DisplayName string `json:"display_name" binding:"required"`
SystemPrompt string `json:"system_prompt"`
IsDefault bool `json:"is_default"`
}
func (h *ChannelModelHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req addChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check max models limit
existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"})
return
}
if len(existing) >= maxModelsPerChannel {
c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"})
return
}
// If this is the first model or marked as default, ensure only one default
isDefault := req.IsDefault || len(existing) == 0
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: req.ModelID,
ProviderConfigID: req.ProviderConfigID,
DisplayName: req.DisplayName,
SystemPrompt: req.SystemPrompt,
IsDefault: isDefault,
}
// SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id)
if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"})
return
}
// If this is the new default, clear default on others
if isDefault {
h.clearOtherDefaults(c, channelID, req.ModelID)
}
// Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusCreated, gin.H{"data": roster})
}
// ── Update ───────────────────────────────────
type updateChannelModelRequest struct {
DisplayName *string `json:"display_name"`
SystemPrompt *string `json:"system_prompt"`
IsDefault *bool `json:"is_default"`
}
func (h *ChannelModelHandler) Update(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req updateChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
fields := make(map[string]interface{})
if req.DisplayName != nil {
fields["display_name"] = *req.DisplayName
}
if req.SystemPrompt != nil {
fields["system_prompt"] = *req.SystemPrompt
}
if req.IsDefault != nil {
fields["is_default"] = *req.IsDefault
if *req.IsDefault {
h.clearOtherDefaults(c, channelID, cm.ModelID)
}
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Delete ───────────────────────────────────
func (h *ChannelModelHandler) Delete(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
// If deleted model was default, promote the first remaining model
if cm.IsDefault {
remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(remaining) > 0 {
h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID,
map[string]interface{}{"is_default": true})
}
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Helpers ──────────────────────────────────
// clearOtherDefaults sets is_default=false on all models in the channel
// except the one with the given modelID.
func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
for _, m := range roster {
if m.ModelID != keepModelID && m.IsDefault {
h.stores.Channels.UpdateModel(c.Request.Context(), m.ID,
map[string]interface{}{"is_default": false})
}
}
}

View File

@@ -1,641 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Request / Response types ────────────────
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
// v0.23.2: DM creation
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off
}
type updateChannelRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
}
type channelResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
AiMode string `json:"ai_mode,omitempty"`
Topic *string `json:"topic,omitempty"`
Description *string `json:"description"`
Model *string `json:"model"`
ProviderConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
FolderID *string `json:"folder_id,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
UnreadCount int `json:"unread_count,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
// ChannelHandler holds dependencies for channel endpoints.
type ChannelHandler struct {
stores store.Stores
}
// NewChannelHandler creates a new channel handler.
func NewChannelHandler(stores store.Stores) *ChannelHandler {
return &ChannelHandler{stores: stores}
}
// channelDeleteHook is called after a channel is successfully deleted.
// Set at startup by SetChannelDeleteHook to clean up storage files.
var channelDeleteHook func(channelID string)
// SetChannelDeleteHook registers a callback invoked after channel deletion.
// Used to clean up channel files on the storage backend.
func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
func getUserID(c *gin.Context) string {
uid, _ := c.Get("user_id")
s, _ := uid.(string)
return s
}
// isSessionAuth returns true if the request is from a session participant (v0.24.3).
func isSessionAuth(c *gin.Context) bool {
return c.GetString("auth_type") == "session"
}
// sessionCanAccessChannel validates that a session participant is authorized
// for the given channel.
func sessionCanAccessChannel(c *gin.Context, channelID string) bool {
if !isSessionAuth(c) {
return false
}
return c.GetString("channel_id") == channelID
}
// parsePagination reads page and per_page from query params with defaults.
func parsePagination(c *gin.Context) (page, perPage, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 100 {
perPage = 100
}
offset = (page - 1) * perPage
return
}
// listItemToResponse converts a store.ChannelListItem to a handler response.
func listItemToResponse(item store.ChannelListItem) channelResponse {
tags := item.Tags
if tags == nil {
tags = []string{}
}
return channelResponse{
ID: item.ID,
UserID: item.UserID,
Title: item.Title,
Type: item.Type,
AiMode: item.AiMode,
Topic: item.Topic,
Description: item.Description,
Model: item.Model,
ProviderConfigID: item.ProviderConfigID,
SystemPrompt: item.SystemPrompt,
IsArchived: item.IsArchived,
IsPinned: item.IsPinned,
Folder: item.Folder,
FolderID: item.FolderID,
ProjectID: item.ProjectID,
WorkspaceID: item.WorkspaceID,
Tags: tags,
Settings: item.Settings,
MessageCount: item.MessageCount,
UnreadCount: item.UnreadCount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// ── List Channels ───────────────────────────
func (h *ChannelHandler) ListChannels(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Optional filters
archived := c.DefaultQuery("archived", "false")
var channelTypes []string
if raw := c.Query("types"); raw != "" {
for _, t := range strings.Split(raw, ",") {
t = strings.TrimSpace(t)
if t != "" {
channelTypes = append(channelTypes, t)
}
}
}
if len(channelTypes) == 0 {
if ct := c.DefaultQuery("type", ""); ct != "" {
channelTypes = []string{ct}
}
}
filter := store.ChannelListFilter{
ListOptions: store.ListOptions{Limit: perPage, Offset: offset},
Archived: archived == "true",
Types: channelTypes,
Folder: c.Query("folder"),
FolderID: c.Query("folder_id"),
Search: strings.TrimSpace(c.Query("search")),
ProjectID: c.Query("project_id"),
}
items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
channels := make([]channelResponse, 0, len(items))
for _, item := range items {
channels = append(channels, listItemToResponse(item))
}
// Resolve DM titles: show the other participant's name, not the creator's label
resolveDMTitles(c.Request.Context(), channels, userID)
SafeJSON(c, http.StatusOK, paginatedResponse{
Data: channels,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
}
// ── Create Channel ──────────────────────────
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
userID := getUserID(c)
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Tags == nil {
req.Tags = []string{}
}
// Default type is "direct" (1:1 AI chat)
channelType := req.Type
if channelType == "" {
channelType = "direct"
}
// Default ai_mode: mention_only for DMs, auto for everything else
aiMode := req.AiMode
if aiMode == "" {
if channelType == "dm" {
aiMode = "mention_only"
} else {
aiMode = "auto"
}
}
ctx := c.Request.Context()
// ── DM dedup: check for existing DM between these two users ──
if channelType == "dm" && len(req.Participants) == 1 {
otherUserID := req.Participants[0]
if otherUserID == userID {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"})
return
}
existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID)
if existingID != "" {
// Return existing channel instead of creating duplicate
item, err := h.stores.Channels.GetForUser(ctx, existingID, userID)
if err == nil {
SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource
return
}
// If read fails, fall through and create new (shouldn't happen)
}
}
// Build channel model
ch := &models.Channel{
UserID: userID,
Title: req.Title,
Type: channelType,
Description: req.Description,
Model: req.Model,
SystemPrompt: req.SystemPrompt,
ProviderConfigID: req.ProviderConfigID,
FolderID: req.FolderID,
}
dmPartners := []string{}
if channelType == "dm" {
dmPartners = req.Participants
}
defaultConfigID := ""
if req.ProviderConfigID != nil {
defaultConfigID = *req.ProviderConfigID
}
if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode,
userID, dmPartners, req.Model, defaultConfigID); err != nil {
log.Printf("[channels] CreateFull error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
// Fetch full response with message count
item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"})
return
}
SafeJSON(c, http.StatusCreated, listItemToResponse(*item))
}
// ── Get Channel ─────────────────────────────
func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
SafeJSON(c, http.StatusOK, listItemToResponse(*item))
}
// ── Update Channel ──────────────────────────
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
ctx := c.Request.Context()
var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership (participants can only move to folder)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if !owns {
// Participants may move channels to their own folders
folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil &&
req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil &&
req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil &&
req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil &&
req.Topic == nil && req.Settings == nil
if !folderOnly {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Verify they are a participant
canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID)
if !canAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
}
// Build fields map for store.Update
fields := map[string]interface{}{}
if req.Title != nil {
fields["title"] = *req.Title
}
if req.Description != nil {
fields["description"] = *req.Description
}
if req.Model != nil {
fields["model"] = *req.Model
}
if req.SystemPrompt != nil {
fields["system_prompt"] = *req.SystemPrompt
}
if req.ProviderConfigID != nil {
if *req.ProviderConfigID == "" {
fields["provider_config_id"] = nil
} else {
fields["provider_config_id"] = *req.ProviderConfigID
}
}
if req.IsArchived != nil {
fields["is_archived"] = *req.IsArchived
}
if req.IsPinned != nil {
fields["is_pinned"] = *req.IsPinned
}
if req.Folder != nil {
fields["folder"] = *req.Folder
}
if req.FolderID != nil {
if *req.FolderID == "" {
fields["folder_id"] = nil // unbind from folder
} else {
fields["folder_id"] = *req.FolderID
}
}
if req.Tags != nil {
fields["tags"] = req.Tags
}
if req.WorkspaceID != nil {
if *req.WorkspaceID == "" {
fields["workspace_id"] = nil // unbind
} else {
fields["workspace_id"] = *req.WorkspaceID
}
}
if req.AiMode != nil {
mode := *req.AiMode
if mode != "auto" && mode != "mention_only" && mode != "off" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"})
return
}
fields["ai_mode"] = mode
}
if req.Topic != nil {
fields["topic"] = *req.Topic
}
hasFields := len(fields) > 0
hasSettings := req.Settings != nil
if !hasFields && !hasSettings {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if hasSettings {
if !json.Valid([]byte(*req.Settings)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"})
return
}
}
if hasFields {
if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
}
if hasSettings {
if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
return
}
}
// Return updated channel
h.GetChannel(c)
}
// ── Delete Channel ──────────────────────────
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
ctx := c.Request.Context()
// Check ownership first (without deleting)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if !owns {
// Not the owner — if participant, leave the channel instead of deleting.
res, _ := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`), channelID, userID)
if rows, _ := res.RowsAffected(); rows > 0 {
c.JSON(http.StatusOK, gin.H{"message": "left channel"})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Check if retention policy applies (global/team provider + TTL > 0)
if h.shouldRetain(ctx, channelID) {
ttl := h.retentionTTL(ctx)
purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour)
if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil {
log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "channel archived for retention",
"purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"),
})
return
}
// Exempt — hard delete
n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID)
if err != nil || n == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}
// shouldRetain returns true if a channel uses a global or team provider
// and the retention TTL is configured (> 0).
func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool {
ttl := h.retentionTTL(ctx)
if ttl <= 0 {
return false
}
// Only BYOK (personal) provider channels are exempt.
// All others — global, team, or no explicit provider — follow retention.
var providerConfigID *string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT provider_config_id FROM channels WHERE id = $1
`), channelID).Scan(&providerConfigID)
if providerConfigID == nil || *providerConfigID == "" {
return true // no explicit provider → defaults to global → retain
}
cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID)
if err != nil {
return true // provider deleted (FK SET NULL already handled above) → retain
}
return cfg.Scope != models.ScopePersonal
}
// retentionTTL reads the configured retention TTL in days from global settings.
func (h *ChannelHandler) retentionTTL(ctx context.Context) int {
cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}
// ── Mark Read (v0.23.2) ───────────────────────
func (h *ChannelHandler) MarkRead(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
err := h.stores.Channels.MarkRead(c.Request.Context(), channelID, userID)
if err != nil {
// Participant row may not exist for legacy direct chats — that's OK
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── DM Title Resolution ──────────────────────
// resolveDMTitles replaces DM channel titles with the other participant's
// display name so each user sees "DM <other_person>" instead of the
// creator's original label.
func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) {
// Collect DM channel IDs
var dmIDs []string
dmIdx := map[string]int{} // channel_id → index in channels slice
for i, ch := range channels {
if ch.Type == "dm" {
dmIDs = append(dmIDs, ch.ID)
dmIdx[ch.ID] = i
}
}
if len(dmIDs) == 0 {
return
}
// Query: for each DM, get the OTHER participant's display name
// Uses channel_participants to find the peer, then joins users for name
placeholders := make([]string, len(dmIDs))
args := make([]interface{}, 0, len(dmIDs)+1)
for i, id := range dmIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
args = append(args, viewerID)
viewerPlaceholder := fmt.Sprintf("$%d", len(args))
query := database.Q(fmt.Sprintf(`
SELECT cp.channel_id,
COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown')
FROM channel_participants cp
JOIN users u ON u.id = cp.participant_id
WHERE cp.channel_id IN (%s)
AND cp.participant_type = 'user'
AND cp.participant_id != %s
LIMIT %d
`, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs)))
rows, err := database.DB.QueryContext(ctx, query, args...)
if err != nil {
return // non-fatal: keep original titles
}
defer rows.Close()
for rows.Next() {
var chID, peerName string
if rows.Scan(&chID, &peerName) == nil {
if idx, ok := dmIdx[chID]; ok {
channels[idx].Title = "DM " + peerName
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,324 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"switchboard-core/events"
"switchboard-core/models"
"switchboard-core/providers"
capspkg "switchboard-core/capabilities"
)
// maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
const maxChainDepth = 5
// chainIfMentioned checks if an assistant response @mentions another
// persona and triggers a follow-up completion. Uses resolveMention()
// directly — no roster or participant list needed. Works in any chat.
//
// Runs asynchronously (goroutine) after the SSE stream closes.
// The follow-up response is delivered to the client via WebSocket events.
func (h *CompletionHandler) chainIfMentioned(
channelID, userID, currentPersonaID, responseContent string,
depth int,
) {
if depth >= maxChainDepth {
return
}
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Extract the @token for logging
token := extractFirstMention(responseContent)
if token == "" {
log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))])
return
}
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
// Use the same resolveMention() that handles user @mentions
mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent)
if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token)
return
}
if mentionPersona == nil {
log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel)
return // no persona @mention found, or it's a raw model (no chain for those)
}
// Don't chain to self
if mentionPersona.ID == currentPersonaID {
log.Printf("[chain] @%s is self-mention, skipping", token)
return
}
log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1)
// Emit typing indicator
if h.hub != nil {
h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true)
defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false)
}
// Brief pause for UX
time.Sleep(500 * time.Millisecond)
// Resolve provider config
configID := mentionConfig
chainReq := completionRequest{
ChannelID: channelID,
Model: mentionModel,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
log.Printf("[chain] Provider %s unavailable: %v", providerID, err)
return
}
// Load conversation with persona's system prompt
messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model)
if err != nil {
log.Printf("[chain] Failed to load conversation: %v", err)
return
}
// Context boundary
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]",
mentionPersona.Name,
)
messages = append(messages, providers.Message{Role: "system", Content: boundary})
caps := capspkg.ResolveIntrinsic(model, nil, nil)
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
}
if mentionPersona.Temperature != nil {
provReq.Temperature = mentionPersona.Temperature
}
if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget
}
// Apply provider-specific hooks
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
// Synchronous completion — result delivered via WebSocket
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err)
if h.health != nil {
h.health.RecordError(configID, latencyMs, err.Error())
}
return
}
if h.health != nil {
h.health.RecordSuccess(configID, latencyMs)
}
if resp.Content == "" {
return
}
// Persist
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID)
if err != nil {
log.Printf("[chain] Failed to persist chained message: %v", err)
return
}
// Deliver via WebSocket to all channel participants
if h.hub != nil {
broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", mentionPersona.ID)
}
// Log usage
if h.stores.Usage != nil {
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: "global",
ModelID: model,
PromptTokens: resp.InputTokens,
CompletionTokens: resp.OutputTokens,
}
_ = h.stores.Usage.Log(ctx, entry)
}
log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s",
mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
// Recursive: check if this response @mentions yet another persona
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
}
// chainToPersona triggers a completion for a specific persona by ID.
// Used by @all fan-out. Does NOT recurse (depth-1 only).
func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain-all] PANIC: %v", r)
}
}()
persona := ResolvePersona(h.stores, personaID, userID)
if persona == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if h.hub != nil {
h.emitTyping(userID, channelID, persona.ID, persona.Name, true)
defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false)
}
configID := ""
if persona.ProviderConfigID != nil {
configID = *persona.ProviderConfigID
}
chainReq := completionRequest{
ChannelID: channelID,
Model: persona.BaseModelID,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
return
}
messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model)
if err != nil {
return
}
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]",
persona.Name,
)
messages = append(messages, providers.Message{Role: "system", Content: boundary})
caps := capspkg.ResolveIntrinsic(model, nil, nil)
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
}
if persona.Temperature != nil {
provReq.Temperature = persona.Temperature
}
if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget
}
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
if h.health != nil {
h.health.RecordError(configID, latencyMs, err.Error())
}
return
}
if h.health != nil {
h.health.RecordSuccess(configID, latencyMs)
}
if resp.Content == "" {
return
}
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID)
if err != nil {
return
}
if h.hub != nil {
broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", persona.ID)
}
if h.stores.Usage != nil {
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: "global",
ModelID: model,
PromptTokens: resp.InputTokens,
CompletionTokens: resp.OutputTokens,
}
_ = h.stores.Usage.Log(ctx, entry)
}
log.Printf("[chain-all] ✅ %s responded (%d tokens) in channel %s",
persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
}
// emitTyping sends a typing indicator via WebSocket.
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
eventType := "typing.start"
if !start {
eventType = "typing.stop"
}
payload, _ := json.Marshal(map[string]string{
"channel_id": channelID,
"participant_id": participantID,
"participant_type": "persona",
"display_name": displayName,
})
h.hub.PublishToUser(userID, events.Event{
Label: eventType,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}

View File

@@ -1,118 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"runtime"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/events"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Dashboard Admin Handler ─────────────────
// GET /api/v1/admin/dashboard
// Aggregates live operational data for the built-in admin monitoring page.
var processStartTime = time.Now()
type DashboardAdminHandler struct {
stores store.Stores
healthStore health.Store
hub *events.Hub
}
func NewDashboardAdminHandler(stores store.Stores, hs health.Store, hub *events.Hub) *DashboardAdminHandler {
return &DashboardAdminHandler{stores: stores, healthStore: hs, hub: hub}
}
type runtimeStats struct {
Goroutines int `json:"goroutines"`
HeapMB int `json:"heap_mb"`
SysMB int `json:"sys_mb"`
NumGC uint32 `json:"num_gc"`
GoVersion string `json:"go_version"`
}
func (h *DashboardAdminHandler) GetDashboard(c *gin.Context) {
ctx := c.Request.Context()
// Provider health summaries
var providerHealth []models.ProviderHealthSummary
windows, err := h.healthStore.ListAllCurrent(ctx)
if err == nil {
for _, w := range windows {
providerHealth = append(providerHealth, models.ProviderHealthSummary{
ProviderConfigID: w.ProviderConfigID,
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
RequestCount: w.RequestCount,
ErrorRate: w.ErrorRate(),
ErrorCount: w.ErrorCount,
RateLimitCount: w.RateLimitCount,
TimeoutCount: w.TimeoutCount,
AvgLatencyMs: w.AvgLatencyMs(),
MaxLatencyMs: w.MaxLatencyMs,
LastError: w.LastError,
LastErrorAt: w.LastErrorAt,
})
}
}
// 24h usage totals
since := time.Now().Add(-24 * time.Hour)
totals, _ := h.stores.Usage.GetTotals(ctx, store.UsageQueryOptions{
Since: &since,
})
// DB pool stats
var dbPool *sql.DBStats
if database.DB != nil {
stats := database.DB.Stats()
dbPool = &stats
}
// WebSocket connections
wsCount := 0
if h.hub != nil {
wsCount = h.hub.ConnCount()
}
// Recent errors from audit log (last 10 error-related entries)
var recentErrors []models.AuditEntry
entries, _, auditErr := h.stores.Audit.List(ctx, store.AuditListOptions{
ListOptions: store.ListOptions{Limit: 10},
ResourceType: "error",
})
if auditErr == nil {
recentErrors = entries
}
// Go runtime stats
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
rt := runtimeStats{
Goroutines: runtime.NumGoroutine(),
HeapMB: int(mem.HeapAlloc / 1024 / 1024),
SysMB: int(mem.Sys / 1024 / 1024),
NumGC: mem.NumGC,
GoVersion: runtime.Version(),
}
// Uptime
uptime := time.Since(processStartTime).Truncate(time.Second).String()
SafeJSON(c, http.StatusOK, gin.H{
"provider_health": providerHealth,
"usage_24h": totals,
"db_pool": dbPool,
"ws_connections": wsCount,
"recent_errors": recentErrors,
"runtime": rt,
"uptime": uptime,
})
}

View File

@@ -1,110 +0,0 @@
package handlers
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// ExportHandler converts markdown content to PDF or DOCX via pandoc.
type ExportHandler struct{}
func NewExportHandler() *ExportHandler { return &ExportHandler{} }
type exportRequest struct {
Content string `json:"content" binding:"required"`
Format string `json:"format" binding:"required"` // "pdf" or "docx"
Filename string `json:"filename"` // optional base name
}
// Convert handles POST /api/v1/export.
// Converts markdown content to the requested format and returns the file.
func (h *ExportHandler) Convert(c *gin.Context) {
var req exportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate format
switch req.Format {
case "pdf", "docx":
// ok
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be pdf or docx"})
return
}
// Check pandoc availability
if _, err := exec.LookPath("pandoc"); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "pandoc is not installed on the server"})
return
}
// Create temp dir for conversion
tmpDir, err := os.MkdirTemp("", "export-*")
if err != nil {
log.Printf("export: failed to create temp dir: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
return
}
defer os.RemoveAll(tmpDir)
// Write markdown to temp file
inputPath := filepath.Join(tmpDir, "input.md")
if err := os.WriteFile(inputPath, []byte(req.Content), 0644); err != nil {
log.Printf("export: failed to write input: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
return
}
// Determine output filename
baseName := "document"
if req.Filename != "" {
baseName = strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
}
outputFile := baseName + "." + req.Format
outputPath := filepath.Join(tmpDir, outputFile)
// Build pandoc command
args := []string{inputPath, "-o", outputPath, "--standalone"}
if req.Format == "pdf" {
// Try to use a lightweight PDF engine
for _, engine := range []string{"weasyprint", "wkhtmltopdf", "pdflatex"} {
if _, err := exec.LookPath(engine); err == nil {
args = append(args, "--pdf-engine="+engine)
break
}
}
}
cmd := exec.CommandContext(c.Request.Context(), "pandoc", args...)
cmd.Dir = tmpDir
if output, err := cmd.CombinedOutput(); err != nil {
log.Printf("export: pandoc failed: %v\n%s", err, string(output))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pandoc conversion failed",
"details": string(output),
})
return
}
// Serve the file
contentType := "application/octet-stream"
switch req.Format {
case "pdf":
contentType = "application/pdf"
case "docx":
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", outputFile))
c.File(outputPath)
c.Header("Content-Type", contentType)
}

View File

@@ -1,565 +0,0 @@
package handlers
// export_data.go — v0.34.0 CS0
//
// Data export endpoints: user data export (GDPR "download my data")
// and team data export (admin). Streams .switchboard zip archives
// directly to the HTTP response.
import (
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/export"
"switchboard-core/models"
"switchboard-core/storage"
"switchboard-core/store"
)
// DataExportHandler serves data export endpoints.
type DataExportHandler struct {
stores store.Stores
objStore storage.ObjectStore
}
// NewDataExportHandler creates a new handler for data export operations.
func NewDataExportHandler(s store.Stores, obj storage.ObjectStore) *DataExportHandler {
return &DataExportHandler{stores: s, objStore: obj}
}
// ExportMyData streams the requesting user's data as a .switchboard zip.
// GET /api/v1/export/me
func (h *DataExportHandler) ExportMyData(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
// ── Fetch user ──
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// ── Fetch all user-scoped entities ──
channels, err := h.stores.Export.UserChannels(ctx, userID)
if err != nil {
slog.Error("export: fetch channels", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channels"})
return
}
channelIDs := make([]string, len(channels))
for i, ch := range channels {
channelIDs[i] = ch.ID
}
messages, err := h.stores.Export.UserMessages(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch messages", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export messages"})
return
}
participants, err := h.stores.Export.UserChannelParticipants(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch participants", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export participants"})
return
}
channelModels, err := h.stores.Export.UserChannelModels(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch channel models", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channel models"})
return
}
cursors, err := h.stores.Export.UserChannelCursors(ctx, userID, channelIDs)
if err != nil {
slog.Error("export: fetch cursors", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export cursors"})
return
}
notes, err := h.stores.Export.UserNotes(ctx, userID)
if err != nil {
slog.Error("export: fetch notes", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notes"})
return
}
noteIDs := make([]string, len(notes))
for i, n := range notes {
noteIDs[i] = n.ID
}
noteLinks, err := h.stores.Export.UserNoteLinks(ctx, noteIDs)
if err != nil {
slog.Error("export: fetch note links", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export note links"})
return
}
memories, err := h.stores.Export.UserMemories(ctx, userID)
if err != nil {
slog.Error("export: fetch memories", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export memories"})
return
}
projects, err := h.stores.Export.UserProjects(ctx, userID)
if err != nil {
slog.Error("export: fetch projects", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export projects"})
return
}
projectIDs := make([]string, len(projects))
for i, p := range projects {
projectIDs[i] = p.ID
}
projectChannels, err := h.stores.Export.UserProjectChannels(ctx, projectIDs)
if err != nil {
slog.Error("export: fetch project channels", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project channels"})
return
}
projectKBs, err := h.stores.Export.UserProjectKBs(ctx, projectIDs)
if err != nil {
slog.Error("export: fetch project KBs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project KBs"})
return
}
projectNotes, err := h.stores.Export.UserProjectNotes(ctx, projectIDs)
if err != nil {
slog.Error("export: fetch project notes", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project notes"})
return
}
workspaces, err := h.stores.Export.UserWorkspaces(ctx, userID)
if err != nil {
slog.Error("export: fetch workspaces", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspaces"})
return
}
workspaceIDs := make([]string, len(workspaces))
for i, w := range workspaces {
workspaceIDs[i] = w.ID
}
workspaceFiles, err := h.stores.Export.UserWorkspaceFiles(ctx, workspaceIDs)
if err != nil {
slog.Error("export: fetch workspace files", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspace files"})
return
}
files, err := h.stores.Export.UserFiles(ctx, userID)
if err != nil {
slog.Error("export: fetch files", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export files"})
return
}
folders, err := h.stores.Export.UserFolders(ctx, userID)
if err != nil {
slog.Error("export: fetch folders", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export folders"})
return
}
userModelSettings, err := h.stores.Export.UserModelSettings(ctx, userID)
if err != nil {
slog.Error("export: fetch user model settings", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export user settings"})
return
}
notifPrefs, err := h.stores.Export.UserNotifPrefs(ctx, userID)
if err != nil {
slog.Error("export: fetch notification prefs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notification prefs"})
return
}
usageEntries, err := h.stores.Export.UserUsageEntries(ctx, userID)
if err != nil {
slog.Error("export: fetch usage entries", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export usage entries"})
return
}
personaGroups, err := h.stores.Export.UserPersonaGroups(ctx, userID)
if err != nil {
slog.Error("export: fetch persona groups", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona groups"})
return
}
pgIDs := make([]string, len(personaGroups))
for i, pg := range personaGroups {
pgIDs[i] = pg.ID
}
personaGroupMembers, err := h.stores.Export.UserPersonaGroupMembers(ctx, pgIDs)
if err != nil {
slog.Error("export: fetch persona group members", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona group members"})
return
}
// ── Build manifest ──
sanitizedUser := export.SanitizeUser(user)
sanitizedChannels := export.SanitizeChannels(channels)
sanitizedMessages := export.SanitizeMessages(messages)
sanitizedChannelModels := export.SanitizeChannelModels(channelModels)
sanitizedUserModelSettings := export.SanitizeUserModelSettings(userModelSettings)
sanitizedUsageEntries := export.SanitizeUsageEntries(usageEntries)
sanitizedWorkspaces := export.SanitizeWorkspaces(workspaces)
counts := map[string]int{
"users": 1,
"channels": len(channels),
"messages": len(messages),
"channel_participants": len(participants),
"channel_models": len(channelModels),
"channel_cursors": len(cursors),
"notes": len(notes),
"note_links": len(noteLinks),
"memories": len(memories),
"projects": len(projects),
"project_channels": len(projectChannels),
"project_knowledge_bases": len(projectKBs),
"project_notes": len(projectNotes),
"workspaces": len(workspaces),
"workspace_files": len(workspaceFiles),
"files": len(files),
"folders": len(folders),
"user_settings": len(userModelSettings),
"notification_preferences": len(notifPrefs),
"usage_entries": len(usageEntries),
"persona_groups": len(personaGroups),
"persona_group_members": len(personaGroupMembers),
}
manifest := &export.Manifest{
Version: "0.34.0",
FormatVersion: export.FormatVersion,
ExportType: "user",
CreatedAt: time.Now().UTC(),
ExportedBy: userID,
EntityCounts: counts,
Scope: &export.ManifestScope{UserID: userID},
}
// ── Stream zip to response ──
filename := fmt.Sprintf("switchboard-export-%s%s", userID[:8], export.ExportExtension)
c.Header("Content-Type", export.ExportContentType)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
aw := export.NewArchiveWriter(c.Writer)
defer aw.Close()
if err := aw.WriteManifest(manifest); err != nil {
slog.Error("export: write manifest", "error", err)
return
}
// Write entity JSON files
writeEntity := func(name string, data interface{}) {
if _, err := aw.WriteEntityJSON(name, data); err != nil {
slog.Error("export: write entity", "name", name, "error", err)
}
}
writeEntity("users", []interface{}{sanitizedUser})
writeEntity("channels", sanitizedChannels)
writeEntity("messages", sanitizedMessages)
writeEntity("channel_participants", participants)
writeEntity("channel_models", sanitizedChannelModels)
writeEntity("channel_cursors", cursors)
writeEntity("notes", notes)
writeEntity("note_links", noteLinks)
writeEntity("memories", memories)
writeEntity("projects", projects)
writeEntity("project_channels", projectChannels)
writeEntity("project_knowledge_bases", projectKBs)
writeEntity("project_notes", projectNotes)
writeEntity("workspaces", sanitizedWorkspaces)
writeEntity("workspace_files", workspaceFiles)
writeEntity("folders", folders)
writeEntity("user_settings", sanitizedUserModelSettings)
writeEntity("notification_preferences", notifPrefs)
writeEntity("usage_entries", sanitizedUsageEntries)
writeEntity("persona_groups", personaGroups)
writeEntity("persona_group_members", personaGroupMembers)
// Write file metadata (without storage_key — already excluded by json:"-" tag)
writeEntity("files", files)
// ── Stream file blobs ──
var warnings []string
if h.objStore != nil {
fileCount := 0
for _, f := range files {
if fileCount >= export.MaxExportFiles {
warnings = append(warnings, "file blob limit reached, some files skipped")
break
}
if f.SizeBytes > export.MaxExportFileSize {
warnings = append(warnings, fmt.Sprintf("file %s too large (%d bytes), skipped", f.Filename, f.SizeBytes))
continue
}
rc, _, _, err := h.objStore.Get(ctx, f.StorageKey)
if err != nil {
warnings = append(warnings, fmt.Sprintf("file %s not found in storage, skipped", f.Filename))
continue
}
zipPath := f.ID + "/" + f.Filename
if _, err := aw.WriteFile(zipPath, rc); err != nil {
rc.Close()
warnings = append(warnings, fmt.Sprintf("file %s write error: %v, skipped", f.Filename, err))
continue
}
rc.Close()
fileCount++
}
}
if len(warnings) > 0 {
writeEntity("export_warnings", warnings)
}
// Audit log
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.export",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"entity_counts": counts},
})
}
// ExportTeam streams a team's data as a .switchboard zip.
// GET /api/v1/admin/teams/:id/export
func (h *DataExportHandler) ExportTeam(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
teamID := c.Param("id")
// Fetch team to verify it exists
team, err := h.stores.Teams.GetByID(ctx, teamID)
if err != nil || team == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Fetch all team-scoped entities
channels, err := h.stores.Export.TeamChannels(ctx, teamID)
if err != nil {
slog.Error("export: fetch team channels", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team channels"})
return
}
channelIDs := make([]string, len(channels))
for i, ch := range channels {
channelIDs[i] = ch.ID
}
messages, err := h.stores.Export.UserMessages(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch team messages", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team messages"})
return
}
members, err := h.stores.Export.TeamMembers(ctx, teamID)
if err != nil {
slog.Error("export: fetch team members", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team members"})
return
}
personas, err := h.stores.Export.TeamPersonas(ctx, teamID)
if err != nil {
slog.Error("export: fetch team personas", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team personas"})
return
}
personaIDs := make([]string, len(personas))
for i, p := range personas {
personaIDs[i] = p.ID
}
personaKBs, err := h.stores.Export.TeamPersonaKBs(ctx, personaIDs)
if err != nil {
slog.Error("export: fetch persona KBs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona KBs"})
return
}
kbs, err := h.stores.Export.TeamKnowledgeBases(ctx, teamID)
if err != nil {
slog.Error("export: fetch team KBs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team KBs"})
return
}
kbIDs := make([]string, len(kbs))
for i, kb := range kbs {
kbIDs[i] = kb.ID
}
kbDocs, err := h.stores.Export.TeamKBDocuments(ctx, kbIDs)
if err != nil {
slog.Error("export: fetch KB docs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export KB documents"})
return
}
workflows, err := h.stores.Export.TeamWorkflows(ctx, teamID)
if err != nil {
slog.Error("export: fetch workflows", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflows"})
return
}
workflowIDs := make([]string, len(workflows))
for i, w := range workflows {
workflowIDs[i] = w.ID
}
workflowVersions, err := h.stores.Export.TeamWorkflowVersions(ctx, workflowIDs)
if err != nil {
slog.Error("export: fetch workflow versions", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow versions"})
return
}
workflowStages, err := h.stores.Export.TeamWorkflowStages(ctx, workflowIDs)
if err != nil {
slog.Error("export: fetch workflow stages", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow stages"})
return
}
groups, err := h.stores.Export.TeamGroups(ctx, teamID)
if err != nil {
slog.Error("export: fetch groups", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export groups"})
return
}
groupIDs := make([]string, len(groups))
for i, g := range groups {
groupIDs[i] = g.ID
}
groupMembers, err := h.stores.Export.TeamGroupMembers(ctx, groupIDs)
if err != nil {
slog.Error("export: fetch group members", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export group members"})
return
}
resourceGrants, err := h.stores.Export.TeamResourceGrants(ctx, teamID)
if err != nil {
slog.Error("export: fetch resource grants", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export resource grants"})
return
}
projects, err := h.stores.Export.TeamProjects(ctx, teamID)
if err != nil {
slog.Error("export: fetch team projects", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team projects"})
return
}
// Sanitize
sanitizedChannels := export.SanitizeChannels(channels)
sanitizedMessages := export.SanitizeMessages(messages)
sanitizedPersonas := export.SanitizePersonas(personas)
sanitizedWorkflows := export.SanitizeWorkflows(workflows)
counts := map[string]int{
"team": 1,
"channels": len(channels),
"messages": len(messages),
"members": len(members),
"personas": len(personas),
"persona_kbs": len(personaKBs),
"knowledge_bases": len(kbs),
"kb_documents": len(kbDocs),
"workflows": len(workflows),
"workflow_versions": len(workflowVersions),
"workflow_stages": len(workflowStages),
"groups": len(groups),
"group_members": len(groupMembers),
"resource_grants": len(resourceGrants),
"projects": len(projects),
}
manifest := &export.Manifest{
Version: "0.34.0",
FormatVersion: export.FormatVersion,
ExportType: "team",
CreatedAt: time.Now().UTC(),
ExportedBy: userID,
EntityCounts: counts,
Scope: &export.ManifestScope{TeamID: teamID},
}
filename := fmt.Sprintf("switchboard-team-%s%s", teamID[:8], export.ExportExtension)
c.Header("Content-Type", export.ExportContentType)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
aw := export.NewArchiveWriter(c.Writer)
defer aw.Close()
if err := aw.WriteManifest(manifest); err != nil {
slog.Error("export: write manifest", "error", err)
return
}
writeEntity := func(name string, data interface{}) {
if _, err := aw.WriteEntityJSON(name, data); err != nil {
slog.Error("export: write entity", "name", name, "error", err)
}
}
writeEntity("team", []interface{}{team})
writeEntity("channels", sanitizedChannels)
writeEntity("messages", sanitizedMessages)
writeEntity("members", members)
writeEntity("personas", sanitizedPersonas)
writeEntity("persona_knowledge_bases", personaKBs)
writeEntity("knowledge_bases", kbs)
writeEntity("kb_documents", kbDocs)
writeEntity("workflows", sanitizedWorkflows)
writeEntity("workflow_versions", workflowVersions)
writeEntity("workflow_stages", workflowStages)
writeEntity("groups", groups)
writeEntity("group_members", groupMembers)
writeEntity("resource_grants", resourceGrants)
writeEntity("projects", projects)
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "team.export",
ResourceType: "team",
ResourceID: teamID,
Metadata: models.JSONMap{"entity_counts": counts},
})
}

View File

@@ -1,166 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
"go.starlark.net/starlark"
)
// ── jsonToStarlark ────────────────────────────────────────────────────────────
func TestJSONToStarlark_String(t *testing.T) {
v := jsonToStarlark("hello")
s, ok := v.(starlark.String)
if !ok || string(s) != "hello" {
t.Errorf("expected starlark.String(hello), got %v (%T)", v, v)
}
}
func TestJSONToStarlark_Int(t *testing.T) {
v := jsonToStarlark(float64(42))
i, ok := v.(starlark.Int)
if !ok {
t.Fatalf("expected starlark.Int, got %T", v)
}
n, _ := i.Int64()
if n != 42 {
t.Errorf("expected 42, got %d", n)
}
}
func TestJSONToStarlark_Float(t *testing.T) {
v := jsonToStarlark(3.14)
if _, ok := v.(starlark.Float); !ok {
t.Errorf("expected starlark.Float, got %T", v)
}
}
func TestJSONToStarlark_Bool(t *testing.T) {
if jsonToStarlark(true) != starlark.True {
t.Error("expected starlark.True")
}
if jsonToStarlark(false) != starlark.False {
t.Error("expected starlark.False")
}
}
func TestJSONToStarlark_Nil(t *testing.T) {
if jsonToStarlark(nil) != starlark.None {
t.Error("expected starlark.None for nil")
}
}
func TestJSONToStarlark_Dict(t *testing.T) {
v := jsonToStarlark(map[string]interface{}{"key": "val"})
d, ok := v.(*starlark.Dict)
if !ok {
t.Fatalf("expected *starlark.Dict, got %T", v)
}
got, found, _ := d.Get(starlark.String("key"))
if !found || string(got.(starlark.String)) != "val" {
t.Errorf("expected key=val in dict, got %v", got)
}
}
func TestJSONToStarlark_List(t *testing.T) {
v := jsonToStarlark([]interface{}{"a", "b"})
l, ok := v.(*starlark.List)
if !ok {
t.Fatalf("expected *starlark.List, got %T", v)
}
if l.Len() != 2 {
t.Errorf("expected 2 elements, got %d", l.Len())
}
}
// ── starlarkValueToGo ─────────────────────────────────────────────────────────
func TestStarlarkValueToGo_String(t *testing.T) {
v := starlarkValueToGo(starlark.String("hi"))
if v != "hi" {
t.Errorf("expected 'hi', got %v", v)
}
}
func TestStarlarkValueToGo_Int(t *testing.T) {
v := starlarkValueToGo(starlark.MakeInt(7))
if v != int64(7) {
t.Errorf("expected int64(7), got %v (%T)", v, v)
}
}
func TestStarlarkValueToGo_Bool(t *testing.T) {
if starlarkValueToGo(starlark.True) != true {
t.Error("expected true")
}
}
func TestStarlarkValueToGo_None(t *testing.T) {
if starlarkValueToGo(starlark.None) != nil {
t.Error("expected nil for starlark.None")
}
}
func TestStarlarkValueToGo_Dict(t *testing.T) {
d := starlark.NewDict(1)
_ = d.SetKey(starlark.String("x"), starlark.MakeInt(1))
v := starlarkValueToGo(d)
m, ok := v.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", v)
}
if m["x"] != int64(1) {
t.Errorf("expected x=1, got %v", m["x"])
}
}
func TestStarlarkValueToGo_List(t *testing.T) {
l := starlark.NewList([]starlark.Value{starlark.String("a"), starlark.String("b")})
v := starlarkValueToGo(l)
arr, ok := v.([]interface{})
if !ok {
t.Fatalf("expected []interface{}, got %T", v)
}
if len(arr) != 2 || arr[0] != "a" {
t.Errorf("unexpected list: %v", arr)
}
}
// ── RoundTrip: JSON → Starlark → Go → JSON ───────────────────────────────────
func TestRoundTrip_JSONToStarlarkToJSON(t *testing.T) {
// Simulate what executeExtensionTool does: parse args, pass as Starlark, convert result back.
argsJSON := `{"query": "hello", "limit": 10, "active": true}`
var raw map[string]interface{}
if err := json.Unmarshal([]byte(argsJSON), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
sv := jsonToStarlark(raw)
got := starlarkValueToGo(sv)
out, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Verify key fields present
var result map[string]interface{}
if err := json.Unmarshal(out, &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result["query"] != "hello" {
t.Errorf("expected query=hello, got %v", result["query"])
}
if result["active"] != true {
t.Errorf("expected active=true, got %v", result["active"])
}
}
// ── BuildExtToolMap ───────────────────────────────────────────────────────────
func TestBuildExtToolMap_Empty(t *testing.T) {
// nil Packages store → returns nil (no panic)
result := BuildExtToolMap(nil, storesWithExtData(newMemExtDataStore()), "user1")
if result != nil && len(result) != 0 {
t.Errorf("expected empty map for stores without Packages, got %v", result)
}
}

View File

@@ -1,993 +0,0 @@
package handlers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/extraction"
"switchboard-core/models"
"switchboard-core/storage"
"switchboard-core/store"
"switchboard-core/workspace"
)
// ── Default Limits ─────────────────────────
// Overridable via global_settings keys.
const (
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
defaultMaxFilesPerMsg = 5 // future: multi-file per message
defaultOrphanMaxAge = 24 * time.Hour
)
// allowedMIMETypes is the default allowlist. Admin can override via global_settings.
var allowedMIMETypes = map[string]bool{
// Images
"image/jpeg": true, "image/png": true, "image/gif": true,
"image/webp": true, "image/svg+xml": true,
// Documents
"application/pdf": true,
"text/plain": true, "text/markdown": true, "text/csv": true,
// Microsoft Office
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/msword": true, "application/vnd.ms-excel": true,
// OpenDocument
"application/vnd.oasis.opendocument.text": true,
"application/vnd.oasis.opendocument.spreadsheet": true,
"application/vnd.oasis.opendocument.presentation": true,
// Other
"application/rtf": true,
}
// ── Handler ────────────────────────────────
type FileHandler struct {
stores store.Stores
objStore storage.ObjectStore
wfs *workspace.FS // nil if workspace FS not configured
extQueue *extraction.Queue // nil if extraction disabled
}
func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler {
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
}
// SetWorkspaceFS attaches the workspace filesystem for project file operations.
func (h *FileHandler) SetWorkspaceFS(wfs *workspace.FS) {
h.wfs = wfs
}
// ── Upload ─────────────────────────────────
// POST /api/v1/channels/:id/files
// Multipart form: file field "file", returns file metadata.
func (h *FileHandler) Upload(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
channelID := c.Param("id")
// Verify channel ownership
if !h.verifyChannelAccess(c, channelID, userID) {
return
}
// Parse multipart
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
// Size check
if header.Size > defaultMaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)),
})
return
}
// MIME detection: read first 512 bytes for sniffing, then reset
buf := make([]byte, 512)
n, _ := file.Read(buf)
detectedType := http.DetectContentType(buf[:n])
// Reset reader to start
if _, err := file.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
return
}
// Normalize MIME type (strip params like charset)
contentType := detectedType
if idx := strings.Index(contentType, ";"); idx > 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
// For types that DetectContentType can't distinguish (returns application/octet-stream),
// fall back to extension-based detection
if contentType == "application/octet-stream" {
ext := strings.ToLower(filepath.Ext(header.Filename))
if mapped, ok := extToMIME[ext]; ok {
contentType = mapped
}
}
// Allowlist check
if !allowedMIMETypes[contentType] {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file type %q not allowed", contentType),
})
return
}
// Build storage key: files are stored under files/{channel_id}/{file_id}_{filename}
// We generate the ID first via a temp UUID, then use it in the key.
att := &models.File{
ChannelID: channelID,
UserID: userID,
Origin: models.FileOriginUserUpload,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
SizeBytes: header.Size,
DisplayHint: displayHintFor(contentType),
Metadata: models.JSONMap{
"extraction_status": "pending",
},
}
// Create PG row first to get the UUID
// storage_key is set after we have the ID
att.StorageKey = "placeholder"
if err := h.stores.Files.Create(c.Request.Context(), att); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"})
return
}
// Now build the real storage key and update
att.StorageKey = fmt.Sprintf("files/%s/%s_%s", channelID, att.ID, att.Filename)
// Write to object store
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
// Rollback PG row on storage failure
h.stores.Files.Delete(c.Request.Context(), att.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
// Update storage_key
h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey)
// For images, mark extraction as not needed (complete immediately)
if strings.HasPrefix(contentType, "image/") {
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
}
// For text/plain, extract inline (trivial — just read the file)
if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" {
if _, err := file.Seek(0, io.SeekStart); err == nil {
if textBytes, err := io.ReadAll(file); err == nil {
text := string(textBytes)
h.stores.Files.SetExtractedText(c.Request.Context(), att.ID, text)
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
}
}
}
// For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar
if extraction.IsExtractable(contentType) && h.extQueue != nil {
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
log.Printf("extraction enqueue failed for %s: %v", att.ID, err)
// Non-fatal: file is uploaded, just won't have extracted text
}
}
c.JSON(http.StatusCreated, att)
}
// ── Download ───────────────────────────────
// GET /api/v1/files/:id/download
// Streams file content with auth check via channel membership.
func (h *FileHandler) Download(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
// Channel-scoped access check
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
defer reader.Close()
c.Header("Content-Type", att.ContentType)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.Status(http.StatusOK)
io.Copy(c.Writer, reader)
}
// ── Get Metadata ───────────────────────────
// GET /api/v1/files/:id
func (h *FileHandler) GetMetadata(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
c.JSON(http.StatusOK, att)
}
// ── List Channel Files ───────────────
// GET /api/v1/channels/:id/files
func (h *FileHandler) ListByChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !h.verifyChannelAccess(c, channelID, userID) {
return
}
origin := c.Query("origin") // v0.37.18: filter by origin (user_upload, tool_output, system)
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, origin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── List by Message ───────────────────────
// GET /api/v1/messages/:id/files
// Returns files attached to a specific message (inline artifacts, tool output).
func (h *FileHandler) ListByMessage(c *gin.Context) {
messageID := c.Param("id")
files, err := h.stores.Files.GetByMessage(c.Request.Context(), messageID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── List by User (File Manager) ───────────
// GET /api/v1/files?page=1&per_page=50
// Paginated list of all files owned by the authenticated user.
func (h *FileHandler) ListByUser(c *gin.Context) {
userID := getUserID(c)
page, perPage, _ := parsePagination(c)
files, total, err := h.stores.Files.GetByUser(c.Request.Context(), userID, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{
"files": files,
"total": total,
"page": page,
"per_page": perPage,
})
}
// ── Delete ─────────────────────────────────
// DELETE /api/v1/files/:id
func (h *FileHandler) Delete(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
// Delete from PG (returns the row for storage cleanup)
deleted, err := h.stores.Files.Delete(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
return
}
// Clean up storage (async-safe: fire and forget with background context)
if h.objStore != nil && deleted != nil {
storageKey := deleted.StorageKey
go func() {
ctx := context.Background()
if err := h.objStore.Delete(ctx, storageKey); err != nil {
log.Printf("storage cleanup failed for %s: %v", storageKey, err)
}
// Also clean up thumbnail if it exists
h.objStore.Delete(ctx, storageKey+"_thumb.jpg")
}()
}
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
}
// ── Admin: Orphan Cleanup ──────────────────
// POST /admin/storage/cleanup
func (h *FileHandler) CleanupOrphans(c *gin.Context) {
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
return
}
deleted := 0
var freedBytes int64
for _, att := range orphans {
if _, err := h.stores.Files.Delete(c.Request.Context(), att.ID); err != nil {
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
continue
}
if h.objStore != nil {
h.objStore.Delete(c.Request.Context(), att.StorageKey)
h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg")
}
deleted++
freedBytes += att.SizeBytes
}
c.JSON(http.StatusOK, gin.H{
"deleted": deleted,
"freed_bytes": freedBytes,
"scanned": len(orphans),
})
}
// ── Admin: Orphan Count ────────────────────
// GET /admin/storage/orphans (for the admin panel card)
func (h *FileHandler) OrphanCount(c *gin.Context) {
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
return
}
var totalBytes int64
for _, att := range orphans {
totalBytes += att.SizeBytes
}
c.JSON(http.StatusOK, gin.H{
"count": len(orphans),
"reclaimable_bytes": totalBytes,
})
}
// ── Admin: Extraction Queue Status ─────────
// GET /admin/storage/extraction
func (h *FileHandler) ExtractionStatus(c *gin.Context) {
if h.extQueue == nil {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"items": []interface{}{},
})
return
}
items, err := h.extQueue.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"})
return
}
if items == nil {
items = []extraction.QueueItem{}
}
// Count by status
counts := map[string]int{}
for _, item := range items {
counts[item.Status]++
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"total": len(items),
"counts": counts,
"items": items,
})
}
// ── Channel Delete Hook ────────────────────
// Called by ChannelHandler.DeleteChannel to clean up storage.
func (h *FileHandler) CleanupChannelStorage(channelID string) {
if h.objStore == nil {
return
}
// CASCADE already deleted PG rows. Clean up filesystem.
prefix := fmt.Sprintf("files/%s", channelID)
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
}
}
// ── Helpers ────────────────────────────────
// verifyChannelAccess checks that the requesting user owns the channel.
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false
}
if !owns {
// Check if user is admin (admins can access any channel)
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return false
}
}
return true
}
// sanitizeFilename cleans a filename for safe storage.
func sanitizeFilename(name string) string {
// Take only the base name (strip path separators)
name = filepath.Base(name)
// Replace problematic characters
replacer := strings.NewReplacer(
"/", "_", "\\", "_", "..", "_", "\x00", "",
)
name = replacer.Replace(name)
if name == "" || name == "." {
name = "unnamed"
}
// Truncate to 200 chars (leave room for UUID prefix in storage key)
if len(name) > 200 {
ext := filepath.Ext(name)
name = name[:200-len(ext)] + ext
}
return name
}
// ── Project Files (v0.22.4, reworked v0.37.17) ─────────────
// Project files route through the Workspace FS subsystem.
// A workspace is auto-created on first file upload (lazy binding).
// verifyProjectAccess checks that the user can access the project.
// Returns true if access is granted (admins bypass).
func (h *FileHandler) verifyProjectAccess(c *gin.Context, projectID, userID string) bool {
if c.GetString("role") == "admin" {
return true
}
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
if err != nil || !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return false
}
return true
}
// ensureProjectWorkspace returns the project's workspace, creating one if needed.
func (h *FileHandler) ensureProjectWorkspace(ctx context.Context, projectID string) (*models.Workspace, error) {
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("project not found: %w", err)
}
// Already has a workspace — load and return it.
if proj.WorkspaceID != nil && *proj.WorkspaceID != "" {
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("workspace %s not found: %w", *proj.WorkspaceID, err)
}
return ws, nil
}
// Create workspace for this project.
ws := &models.Workspace{
OwnerType: models.WorkspaceOwnerProject,
OwnerID: projectID,
Name: proj.Name + " Files",
Status: models.WorkspaceStatusActive,
}
ws.ID = store.NewID()
ws.RootPath = "workspaces/" + ws.ID
if err := h.stores.Workspaces.Create(ctx, ws); err != nil {
// Race guard: another request may have created it. Re-read the project.
proj2, err2 := h.stores.Projects.GetByID(ctx, projectID)
if err2 == nil && proj2.WorkspaceID != nil && *proj2.WorkspaceID != "" {
return h.stores.Workspaces.GetByID(ctx, *proj2.WorkspaceID)
}
return nil, fmt.Errorf("failed to create workspace: %w", err)
}
// Create directory on disk.
if err := h.wfs.CreateDir(ws); err != nil {
log.Printf("warning: workspace mkdir for project %s: %v", projectID, err)
}
// Bind workspace to project.
h.stores.Projects.Update(ctx, projectID, models.ProjectPatch{WorkspaceID: &ws.ID})
return ws, nil
}
// UploadToProject handles project-scoped file uploads via workspace FS.
// POST /api/v1/projects/:id/files
func (h *FileHandler) UploadToProject(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
log.Printf("error: ensure workspace for project %s: %v", projectID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
if header.Size > int64(defaultMaxFileSize) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds size limit"})
return
}
// Quota check
stats, _ := h.stores.Workspaces.GetStats(ctx, ws.ID)
quota := workspace.DefaultMaxBytes
if ws.MaxBytes != nil {
quota = *ws.MaxBytes
}
if stats != nil && stats.TotalBytes+header.Size > quota {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
})
return
}
// Detect content type
buf := make([]byte, 512)
n, _ := file.Read(buf)
contentType := http.DetectContentType(buf[:n])
file.Seek(0, io.SeekStart)
ext := strings.ToLower(filepath.Ext(header.Filename))
if better, ok := extToMIME[ext]; ok && contentType == "application/octet-stream" {
contentType = better
}
filename := sanitizeFilename(header.Filename)
// Determine upload path (support optional path prefix via query param)
uploadPath := filename
if prefix := c.Query("path"); prefix != "" {
uploadPath = strings.TrimSuffix(prefix, "/") + "/" + filename
}
if err := h.wfs.WriteFile(ctx, ws, uploadPath, file, header.Size); err != nil {
log.Printf("error: project file write: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
c.JSON(http.StatusCreated, gin.H{
"path": uploadPath,
"filename": filename,
"content_type": contentType,
"size_bytes": header.Size,
})
}
// ListByProject returns workspace files for a project.
// GET /api/v1/projects/:id/files
func (h *FileHandler) ListByProject(c *gin.Context) {
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
// Load project to get workspace_id
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// No workspace yet → empty list
if proj.WorkspaceID == nil || *proj.WorkspaceID == "" {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
return
}
dirPath := c.DefaultQuery("path", "")
recursive := c.DefaultQuery("recursive", "true") == "true"
files, err := h.wfs.ListDir(ctx, ws, dirPath, recursive)
if err != nil {
log.Printf("error: list project files: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.WorkspaceFile{}
}
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
}
// DownloadProjectFile streams a single file from the project workspace.
// GET /api/v1/projects/:id/files/download?path=...
func (h *FileHandler) DownloadProjectFile(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
rc, size, err := h.wfs.ReadFile(ctx, ws, filePath)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
defer rc.Close()
// Detect content type
f, _ := h.wfs.Stat(ctx, ws, filePath)
contentType := "application/octet-stream"
if f != nil && f.ContentType != "" {
contentType = f.ContentType
}
filename := filepath.Base(filePath)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
}
// DeleteProjectFile deletes a file or directory from the project workspace.
// DELETE /api/v1/projects/:id/files?path=...
func (h *FileHandler) DeleteProjectFile(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
recursive := c.Query("recursive") == "true"
if err := h.wfs.DeleteFile(ctx, ws, filePath, recursive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// MkdirProject creates a directory in the project workspace.
// POST /api/v1/projects/:id/files/mkdir
func (h *FileHandler) MkdirProject(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
// Accept path from query param or JSON body
dirPath := c.Query("path")
if dirPath == "" {
var body struct {
Path string `json:"path"`
}
if c.ShouldBindJSON(&body) == nil && body.Path != "" {
dirPath = body.Path
}
}
if dirPath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
if err := h.wfs.Mkdir(ctx, ws, dirPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": dirPath})
}
// UploadProjectArchive extracts a zip/tar.gz archive into the project workspace.
// POST /api/v1/projects/:id/archive/upload
func (h *FileHandler) UploadProjectArchive(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
return
}
defer file.Close()
format := detectArchiveFormat(header.Filename)
if format == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
return
}
// Save to temp file (zip extraction needs seekable file)
tmp, err := os.CreateTemp("", "project-archive-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
count, err := h.wfs.ExtractArchive(ctx, ws, tmpName, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "files_extracted": count})
}
// DownloadProjectArchive creates and streams a zip/tar.gz of all project files.
// GET /api/v1/projects/:id/archive/download?format=zip
func (h *FileHandler) DownloadProjectArchive(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
format := c.DefaultQuery("format", "zip")
if format != "zip" && format != "tar.gz" && format != "tgz" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
return
}
archivePath, err := h.wfs.CreateArchive(ctx, ws, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.Remove(archivePath)
ext := "zip"
if format == "tar.gz" || format == "tgz" {
ext = "tar.gz"
}
filename := fmt.Sprintf("%s.%s", proj.Name, ext)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.File(archivePath)
}
// extToMIME maps file extensions to MIME types for cases where
// http.DetectContentType returns application/octet-stream.
var extToMIME = map[string]string{
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".doc": "application/msword",
".xls": "application/vnd.ms-excel",
".odt": "application/vnd.oasis.opendocument.text",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odp": "application/vnd.oasis.opendocument.presentation",
".rtf": "application/rtf",
".md": "text/markdown",
".csv": "text/csv",
".svg": "image/svg+xml",
}
// displayHintFor returns the appropriate display hint for a content type.
func displayHintFor(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/"):
return models.FileHintInline
case strings.HasPrefix(contentType, "video/"):
return models.FileHintInline
case strings.HasPrefix(contentType, "audio/"):
return models.FileHintInline
case contentType == "application/pdf":
return models.FileHintThumbnail
case strings.HasPrefix(contentType, "text/"):
return models.FileHintInline
case contentType == "application/json":
return models.FileHintInline
default:
return models.FileHintDownload
}
}

View File

@@ -1,127 +0,0 @@
package handlers
// folders.go — Chat folder CRUD (v0.23.1)
//
// v0.29.0: Raw SQL replaced with FolderStore methods.
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
type FolderHandler struct {
stores store.Stores
}
func NewFolderHandler(s store.Stores) *FolderHandler {
return &FolderHandler{stores: s}
}
func (h *FolderHandler) List(c *gin.Context) {
userID := getUserID(c)
folders, err := h.stores.Folders.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
c.JSON(http.StatusOK, gin.H{"data": folders})
}
func (h *FolderHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
ParentID *string `json:"parent_id"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
f := &models.Folder{
UserID: userID,
Name: req.Name,
ParentID: req.ParentID,
SortOrder: req.SortOrder,
}
if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"folder": f})
}
func (h *FolderHandler) Update(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
// Read raw body to detect which fields were explicitly sent
data, err := c.GetRawData()
if err != nil || len(data) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
ParentID *string `json:"parent_id"`
}
if err := json.Unmarshal(data, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
}
// Only update parent_id if it was explicitly present in the JSON
var parentID **string
var raw map[string]json.RawMessage
_ = json.Unmarshal(data, &raw)
if _, ok := raw["parent_id"]; ok {
parentID = &req.ParentID
}
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder, parentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *FolderHandler) Delete(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
// Unassign chats before deleting folder
_ = h.stores.Folders.UnassignChannels(c.Request.Context(), folderID, userID)
n, err := h.stores.Folders.Delete(c.Request.Context(), folderID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -1,130 +0,0 @@
package handlers
// gdpr.go — v0.34.0 CS2
//
// GDPR delete endpoint: DELETE /api/v1/me
// Allows a user to delete their own account and all associated data.
import (
"crypto/sha256"
"fmt"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"switchboard-core/models"
"switchboard-core/store"
)
// GDPRHandler serves account deletion endpoints.
type GDPRHandler struct {
stores store.Stores
}
// NewGDPRHandler creates a new handler for GDPR operations.
func NewGDPRHandler(s store.Stores) *GDPRHandler {
return &GDPRHandler{stores: s}
}
// deleteAccountRequest is the body for DELETE /api/v1/me.
type deleteAccountRequest struct {
Confirm string `json:"confirm"`
Password string `json:"password"`
}
// DeleteMyAccount permanently deletes the requesting user's account.
// DELETE /api/v1/me
func (h *GDPRHandler) DeleteMyAccount(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
var req deleteAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Require explicit confirmation
if req.Confirm != "DELETE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "confirm field must be \"DELETE\""})
return
}
// Fetch user
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
return
}
// Prevent deleting last admin
if user.Role == "admin" {
adminCount, err := h.stores.Export.CountActiveAdmins(ctx)
if err != nil {
slog.Error("gdpr: count admins", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin count"})
return
}
if adminCount <= 1 {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete the last admin account"})
return
}
}
// Generate anonymized hash from user ID
hash := sha256.Sum256([]byte(userID))
anonHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars
// Step 1: Soft-delete/hard-delete user data
counts, err := h.stores.Export.SoftDeleteUserData(ctx, userID)
if err != nil {
slog.Error("gdpr: delete user data", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user data"})
return
}
// Step 2: Revoke tokens
if err := h.stores.Export.DeleteUserTokens(ctx, userID); err != nil {
slog.Error("gdpr: delete tokens", "error", err, "user_id", userID)
// Continue — tokens will expire naturally
}
// Step 3: Delete personal provider configs
if deleted, err := h.stores.Providers.DeletePersonalByOwner(ctx, userID); err != nil {
slog.Error("gdpr: delete provider configs", "error", err)
} else {
counts["provider_configs"] = deleted
}
// Step 4: Anonymize user record
if err := h.stores.Export.AnonymizeUser(ctx, userID, anonHash); err != nil {
slog.Error("gdpr: anonymize user", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to anonymize user"})
return
}
// Audit log (before the user record is fully anonymized)
anonUsername := "deleted-user-" + anonHash
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.gdpr_delete",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"anonymized_as": anonUsername, "deleted_counts": counts},
})
slog.Info("gdpr: account deleted", "user_id", userID, "anonymized_as", anonUsername)
c.JSON(http.StatusOK, gin.H{
"message": "account deleted",
"anonymized_as": anonUsername,
})
}

View File

@@ -1,436 +0,0 @@
package handlers
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"strconv"
"golang.org/x/crypto/ssh"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workspace"
"github.com/gin-gonic/gin"
)
// =========================================
// GIT HANDLER — Git operations on workspaces
// =========================================
type GitHandler struct {
stores store.Stores
gitOps *workspace.GitOps
}
func NewGitHandler(s store.Stores, gitOps *workspace.GitOps) *GitHandler {
return &GitHandler{stores: s, gitOps: gitOps}
}
// ── Clone ────────────────────────────────────
func (h *GitHandler) Clone(c *gin.Context) {
wsID := c.Param("id")
var req struct {
URL string `json:"url" binding:"required"`
Branch string `json:"branch"`
CredentialID string `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
if err := h.gitOps.Clone(c.Request.Context(), w, req.URL, req.Branch, req.CredentialID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "cloned"})
}
// ── Pull ─────────────────────────────────────
func (h *GitHandler) Pull(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
if err := h.gitOps.Pull(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "pulled"})
}
// ── Push ─────────────────────────────────────
func (h *GitHandler) Push(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
if err := h.gitOps.Push(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "pushed"})
}
// ── Status ───────────────────────────────────
func (h *GitHandler) Status(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
status, err := h.gitOps.Status(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, status)
}
// ── Diff ─────────────────────────────────────
func (h *GitHandler) Diff(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
path := c.Query("path")
diff, err := h.gitOps.Diff(c.Request.Context(), w, path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"diff": diff})
}
// ── Commit ───────────────────────────────────
func (h *GitHandler) Commit(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
var req struct {
Message string `json:"message" binding:"required"`
Paths []string `json:"paths"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gitOps.Commit(c.Request.Context(), w, req.Message, req.Paths); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "committed"})
}
// ── Log ──────────────────────────────────────
func (h *GitHandler) Log(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
n := 20
if v := c.Query("n"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
if n > 100 {
n = 100
}
}
}
entries, err := h.gitOps.Log(c.Request.Context(), w, n)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if entries == nil {
entries = []models.GitLogEntry{}
}
c.JSON(http.StatusOK, gin.H{"data": entries})
}
// ── Branches ─────────────────────────────────
func (h *GitHandler) Branches(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
branches, current, err := h.gitOps.BranchList(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"current": current,
"branches": branches,
})
}
// ── Checkout ─────────────────────────────────
func (h *GitHandler) Checkout(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
var req struct {
Branch string `json:"branch" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gitOps.Checkout(c.Request.Context(), w, req.Branch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "checked out", "branch": req.Branch})
}
// loadWorkspace loads the workspace and validates git is configured.
func (h *GitHandler) loadWorkspace(c *gin.Context) (*models.Workspace, error) {
wsID := c.Param("id")
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return nil, err
}
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
err := fmt.Errorf("workspace has no git remote configured")
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, err
}
return w, nil
}
// =========================================
// GIT CREDENTIAL HANDLER — CRUD for encrypted git credentials
// =========================================
type GitCredentialHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewGitCredentialHandler(s store.Stores, vault *crypto.KeyResolver) *GitCredentialHandler {
return &GitCredentialHandler{stores: s, vault: vault}
}
// Create stores a new encrypted git credential.
func (h *GitCredentialHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
AuthType string `json:"auth_type" binding:"required"`
// Raw credential data — encrypted before storage
Token string `json:"token,omitempty"` // for https_pat
Username string `json:"username,omitempty"` // for https_basic
Password string `json:"password,omitempty"` // for https_basic
PrivateKey string `json:"private_key,omitempty"` // for ssh_key
Passphrase string `json:"passphrase,omitempty"` // for ssh_key
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate auth type
switch req.AuthType {
case "https_pat":
if req.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required for https_pat"})
return
}
case "https_basic":
if req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required for https_basic"})
return
}
case "ssh_key":
if req.PrivateKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "private_key is required for ssh_key"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "auth_type must be https_pat, https_basic, or ssh_key"})
return
}
// Build plaintext JSON
var credData map[string]string
switch req.AuthType {
case "https_pat":
credData = map[string]string{"token": req.Token}
case "https_basic":
credData = map[string]string{"username": req.Username, "password": req.Password}
case "ssh_key":
credData = map[string]string{"private_key": req.PrivateKey, "passphrase": req.Passphrase}
}
plaintext, _ := json.Marshal(credData)
// Encrypt using global scope (same vault pattern as BYOK)
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
return
}
cred := &models.GitCredential{
UserID: userID,
Name: req.Name,
AuthType: req.AuthType,
EncryptedData: ciphertext,
Nonce: nonce,
}
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cred.Summary())
}
// List returns all git credentials for the current user (summaries only).
func (h *GitCredentialHandler) List(c *gin.Context) {
userID := getUserID(c)
creds, err := h.stores.GitCredentials.ListByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
summaries := make([]models.GitCredentialSummary, 0, len(creds))
for _, cred := range creds {
summaries = append(summaries, cred.Summary())
}
c.JSON(http.StatusOK, gin.H{"data": summaries})
}
// Delete removes a git credential.
func (h *GitCredentialHandler) Delete(c *gin.Context) {
userID := getUserID(c)
credID := c.Param("id")
if err := h.stores.GitCredentials.Delete(c.Request.Context(), credID, userID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// Generate creates a new ED25519 SSH keypair server-side.
// The private key is vault-encrypted before storage. Only the public key
// and fingerprint are returned — the private key never leaves the server.
//
// POST /git-credentials/generate
func (h *GitCredentialHandler) Generate(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
PersonaID *string `json:"persona_id,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate ED25519 keypair
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
return
}
// Marshal public key to authorized_keys format
sshPub, err := ssh.NewPublicKey(pubKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"})
return
}
authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub))
// Fingerprint (SHA256)
fingerprint := ssh.FingerprintSHA256(sshPub)
// Marshal private key to OpenSSH PEM format
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"})
return
}
privPEMBytes := pem.EncodeToMemory(privPEM)
// Encrypt private key via vault
credData := map[string]string{"private_key": string(privPEMBytes)}
plaintext, _ := json.Marshal(credData)
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
return
}
cred := &models.GitCredential{
UserID: userID,
Name: req.Name,
AuthType: "ssh_key",
EncryptedData: ciphertext,
Nonce: nonce,
PublicKey: authorizedKey,
Fingerprint: fingerprint,
PersonaID: req.PersonaID,
}
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cred.Summary())
}
// GetPublicKey returns the public key for a credential (for copy-to-clipboard).
//
// GET /git-credentials/:id/public-key
func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) {
userID := getUserID(c)
credID := c.Param("id")
cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID)
if err != nil || cred.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
if cred.PublicKey == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"})
return
}
c.JSON(http.StatusOK, gin.H{
"public_key": cred.PublicKey,
"fingerprint": cred.Fingerprint,
})
}

View File

@@ -1,281 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/crypto"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Git Credentials Test Harness ──────────
type gitCredHarness struct {
*testHarness
userToken string
userID string
user2Token string
user2ID string
}
func setupGitCredHarness(t *testing.T) *gitCredHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
// Create a test vault with a static env key (32 bytes for AES-256)
envKey := []byte("test-encryption-key-32-bytes!!")
for len(envKey) < 32 {
envKey = append(envKey, '0')
}
envKey = envKey[:32]
vault := crypto.NewKeyResolver(envKey, nil)
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
gitCredH := NewGitCredentialHandler(stores, vault)
protected.POST("/git-credentials", gitCredH.Create)
protected.POST("/git-credentials/generate", gitCredH.Generate)
protected.GET("/git-credentials", gitCredH.List)
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
// Seed users
userID := database.SeedTestUser(t, "gituser", "gituser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "gituser@test.com", "user")
user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
user2Token := makeToken(user2ID, "gituser2@test.com", "user")
return &gitCredHarness{
testHarness: &testHarness{router: r, t: t},
userToken: userToken,
userID: userID,
user2Token: user2Token,
user2ID: user2ID,
}
}
// ── GET /git-credentials — empty state ───
func TestGitCreds_List_Empty(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
data, ok := body["data"]
if !ok {
t.Fatal("response must have 'data' key")
}
arr := data.([]interface{})
if len(arr) != 0 {
t.Fatalf("expected empty array, got %d items", len(arr))
}
}
// ── POST /git-credentials/generate ───────
func TestGitCreds_Generate(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Test Key",
})
if resp.Code != http.StatusCreated {
t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String())
}
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
// Must have public_key and fingerprint
pubKey, _ := cred["public_key"].(string)
fp, _ := cred["fingerprint"].(string)
if pubKey == "" {
t.Error("public_key should be non-empty")
}
if fp == "" {
t.Error("fingerprint should be non-empty")
}
if cred["auth_type"] != "ssh_key" {
t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"])
}
if cred["name"] != "Test Key" {
t.Errorf("name: got %v", cred["name"])
}
// Must NOT have encrypted data
if _, has := cred["encrypted_data"]; has {
t.Error("encrypted_data must not appear in response")
}
if _, has := cred["nonce"]; has {
t.Error("nonce must not appear in response")
}
}
// ── GET /git-credentials/:id/public-key ──
func TestGitCreds_GetPublicKey(t *testing.T) {
h := setupGitCredHarness(t)
// Generate a key first
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "PK Test",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
origPK := cred["public_key"].(string)
// Retrieve public key
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String())
}
var pkBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&pkBody)
if pkBody["public_key"] != origPK {
t.Errorf("public key mismatch")
}
if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" {
t.Error("fingerprint should be present")
}
}
// ── List after generate ──────────────────
func TestGitCreds_ListAfterGenerate(t *testing.T) {
h := setupGitCredHarness(t)
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Key A",
})
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Key B",
})
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 2 {
t.Fatalf("expected 2 keys, got %d", len(arr))
}
// Verify summaries have public_key + fingerprint
for _, raw := range arr {
item := raw.(map[string]interface{})
if item["public_key"] == nil || item["public_key"] == "" {
t.Error("list item should have public_key")
}
if item["fingerprint"] == nil || item["fingerprint"] == "" {
t.Error("list item should have fingerprint")
}
}
}
// ── Delete ───────────────────────────────
func TestGitCreds_Delete(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Delete Me",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
// Delete
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("delete: got %d", resp.Code)
}
// Verify gone
resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("expected 0 keys after delete, got %d", len(arr))
}
}
// ── User isolation ───────────────────────
func TestGitCreds_UserIsolation(t *testing.T) {
h := setupGitCredHarness(t)
// User 1 generates a key
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "User1 Key",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
// User 2 should see empty list
resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("user2 should see 0 keys, got %d", len(arr))
}
// User 2 should not be able to get user1's public key
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-user public-key: want 404, got %d", resp.Code)
}
// User 2 should not be able to delete user1's key
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-user delete: want 404, got %d", resp.Code)
}
}
// ── Validation ───────────────────────────
func TestGitCreds_Generate_MissingName(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing name: want 400, got %d", resp.Code)
}
}

View File

@@ -1,733 +0,0 @@
package handlers
// import_data.go — v0.34.0 CS1
//
// Data import endpoint: user data import from .switchboard archives.
// Reads the archive, validates manifest, and imports entities in
// FK-dependency order with UUID dedup (skip if ID exists).
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/export"
"switchboard-core/models"
"switchboard-core/storage"
"switchboard-core/store"
)
// DataImportHandler serves data import endpoints.
type DataImportHandler struct {
stores store.Stores
objStore storage.ObjectStore
}
// NewDataImportHandler creates a new handler for data import operations.
func NewDataImportHandler(s store.Stores, obj storage.ObjectStore) *DataImportHandler {
return &DataImportHandler{stores: s, objStore: obj}
}
// ImportMyData imports user data from a .switchboard zip archive.
// POST /api/v1/import/me
func (h *DataImportHandler) ImportMyData(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
// ── Receive upload ──
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
// Validate extension
if !strings.HasSuffix(header.Filename, export.ExportExtension) && !strings.HasSuffix(header.Filename, ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .switchboard or .zip archive"})
return
}
// Size check: use MaxExportArchiveSize
if header.Size > export.MaxExportArchiveSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
})
return
}
// Save to temp file (zip.OpenReader needs a file path)
tmp, err := os.CreateTemp("", "switchboard-import-*.zip")
if err != nil {
slog.Error("import: create temp file", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
slog.Error("import: save temp file", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
// ── Open archive ──
ar, err := export.OpenArchive(tmp.Name())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
return
}
defer ar.Close()
manifest, err := ar.ReadManifest()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()})
return
}
if manifest.FormatVersion > export.FormatVersion {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("unsupported format version %d (max %d)", manifest.FormatVersion, export.FormatVersion),
})
return
}
// ── Read entities from archive ──
imported := make(map[string]int)
skipped := make(map[string]int)
var errors []string
importEntity := func(name string, fn func() (int, int, error)) {
imp, skip, err := fn()
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
slog.Error("import: entity error", "entity", name, "error", err)
}
if imp > 0 {
imported[name] = imp
}
if skip > 0 {
skipped[name] = skip
}
}
// Helper to remap user_id on entities for cross-instance import
remapUserID := manifest.Scope != nil && manifest.Scope.UserID != "" && manifest.Scope.UserID != userID
sourceUserID := ""
if manifest.Scope != nil {
sourceUserID = manifest.Scope.UserID
}
// 1. Folders
var folders []models.Folder
if err := ar.ReadEntityJSON("folders", &folders); err == nil && len(folders) > 0 {
if remapUserID {
for i := range folders {
if folders[i].UserID == sourceUserID {
folders[i].UserID = userID
}
}
}
importEntity("folders", func() (int, int, error) {
return h.stores.Export.ImportFolders(ctx, folders)
})
}
// 2. Projects
var projects []models.Project
if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 {
if remapUserID {
for i := range projects {
if projects[i].OwnerID == sourceUserID {
projects[i].OwnerID = userID
}
}
}
importEntity("projects", func() (int, int, error) {
return h.stores.Export.ImportProjects(ctx, projects)
})
}
// 3. Workspaces
var workspaces []models.Workspace
if err := ar.ReadEntityJSON("workspaces", &workspaces); err == nil && len(workspaces) > 0 {
if remapUserID {
for i := range workspaces {
if workspaces[i].OwnerID == sourceUserID {
workspaces[i].OwnerID = userID
}
}
}
importEntity("workspaces", func() (int, int, error) {
return h.stores.Export.ImportWorkspaces(ctx, workspaces)
})
}
// 4. Channels
var channels []models.Channel
if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 {
if remapUserID {
for i := range channels {
if channels[i].UserID == sourceUserID {
channels[i].UserID = userID
}
}
}
importEntity("channels", func() (int, int, error) {
return h.stores.Export.ImportChannels(ctx, channels)
})
}
// 5. Channel participants
var participants []models.ChannelParticipant
if err := ar.ReadEntityJSON("channel_participants", &participants); err == nil && len(participants) > 0 {
if remapUserID {
for i := range participants {
if participants[i].ParticipantType == "user" && participants[i].ParticipantID == sourceUserID {
participants[i].ParticipantID = userID
}
}
}
importEntity("channel_participants", func() (int, int, error) {
return h.stores.Export.ImportChannelParticipants(ctx, participants)
})
}
// 5b. Channel models
var channelModels []models.ChannelModel
if err := ar.ReadEntityJSON("channel_models", &channelModels); err == nil && len(channelModels) > 0 {
importEntity("channel_models", func() (int, int, error) {
return h.stores.Export.ImportChannelModels(ctx, channelModels)
})
}
// 5c. Channel cursors
var cursors []models.ChannelCursor
if err := ar.ReadEntityJSON("channel_cursors", &cursors); err == nil && len(cursors) > 0 {
if remapUserID {
for i := range cursors {
if cursors[i].UserID == sourceUserID {
cursors[i].UserID = userID
}
}
}
importEntity("channel_cursors", func() (int, int, error) {
return h.stores.Export.ImportChannelCursors(ctx, cursors)
})
}
// 6. Messages (ordered by created_at ASC for parent_id integrity)
var messages []models.Message
if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 {
if remapUserID {
for i := range messages {
if messages[i].ParticipantType == "user" && messages[i].ParticipantID == sourceUserID {
messages[i].ParticipantID = userID
}
}
}
importEntity("messages", func() (int, int, error) {
return h.stores.Export.ImportMessages(ctx, messages)
})
}
// 7. Notes
var notes []models.Note
if err := ar.ReadEntityJSON("notes", &notes); err == nil && len(notes) > 0 {
if remapUserID {
for i := range notes {
if notes[i].UserID == sourceUserID {
notes[i].UserID = userID
}
}
}
importEntity("notes", func() (int, int, error) {
return h.stores.Export.ImportNotes(ctx, notes)
})
}
// 8. Note links
var noteLinks []models.ExportNoteLink
if err := ar.ReadEntityJSON("note_links", &noteLinks); err == nil && len(noteLinks) > 0 {
importEntity("note_links", func() (int, int, error) {
return h.stores.Export.ImportNoteLinks(ctx, noteLinks)
})
}
// 9. Memories
var memories []models.Memory
if err := ar.ReadEntityJSON("memories", &memories); err == nil && len(memories) > 0 {
if remapUserID {
for i := range memories {
if memories[i].OwnerID == sourceUserID {
memories[i].OwnerID = userID
}
}
}
importEntity("memories", func() (int, int, error) {
return h.stores.Export.ImportMemories(ctx, memories)
})
}
// 10. Project channels
var projectChannels []models.ProjectChannel
if err := ar.ReadEntityJSON("project_channels", &projectChannels); err == nil && len(projectChannels) > 0 {
importEntity("project_channels", func() (int, int, error) {
return h.stores.Export.ImportProjectChannels(ctx, projectChannels)
})
}
// 10b. Project KBs
var projectKBs []models.ProjectKB
if err := ar.ReadEntityJSON("project_knowledge_bases", &projectKBs); err == nil && len(projectKBs) > 0 {
importEntity("project_knowledge_bases", func() (int, int, error) {
return h.stores.Export.ImportProjectKBs(ctx, projectKBs)
})
}
// 10c. Project notes
var projectNotes []models.ProjectNote
if err := ar.ReadEntityJSON("project_notes", &projectNotes); err == nil && len(projectNotes) > 0 {
importEntity("project_notes", func() (int, int, error) {
return h.stores.Export.ImportProjectNotes(ctx, projectNotes)
})
}
// 11. Workspace files
var workspaceFiles []models.WorkspaceFile
if err := ar.ReadEntityJSON("workspace_files", &workspaceFiles); err == nil && len(workspaceFiles) > 0 {
importEntity("workspace_files", func() (int, int, error) {
return h.stores.Export.ImportWorkspaceFiles(ctx, workspaceFiles)
})
}
// 12. Files (metadata)
var files []models.File
if err := ar.ReadEntityJSON("files", &files); err == nil && len(files) > 0 {
if remapUserID {
for i := range files {
if files[i].UserID == sourceUserID {
files[i].UserID = userID
}
}
}
// Generate storage keys for imported files
for i := range files {
if files[i].StorageKey == "" && files[i].ChannelID != "" {
files[i].StorageKey = fmt.Sprintf("files/%s/%s_%s", files[i].ChannelID, files[i].ID, files[i].Filename)
}
}
importEntity("files", func() (int, int, error) {
return h.stores.Export.ImportFiles(ctx, files)
})
}
// 12b. File blobs from archive
if h.objStore != nil {
fileBlobs := ar.FileEntries()
blobCount := 0
for _, entry := range fileBlobs {
if blobCount >= export.MaxExportFiles {
errors = append(errors, "file blob limit reached, some files skipped")
break
}
// Extract file ID from path: files/{fileID}/{filename}
parts := strings.SplitN(strings.TrimPrefix(entry.Name, "files/"), "/", 2)
if len(parts) != 2 {
continue
}
fileID := parts[0]
filename := parts[1]
// Find matching file record for storage key
storageKey := ""
var contentType string
for _, f := range files {
if f.ID == fileID {
storageKey = f.StorageKey
contentType = f.ContentType
break
}
}
if storageKey == "" {
storageKey = fmt.Sprintf("files/imported/%s_%s", fileID, filename)
}
if contentType == "" {
contentType = "application/octet-stream"
}
rc, err := entry.Open()
if err != nil {
errors = append(errors, fmt.Sprintf("file %s: open error", filepath.Base(entry.Name)))
continue
}
if err := h.objStore.Put(ctx, storageKey, rc, int64(entry.UncompressedSize64), contentType); err != nil {
rc.Close()
errors = append(errors, fmt.Sprintf("file %s: upload error", filepath.Base(entry.Name)))
continue
}
rc.Close()
blobCount++
}
if blobCount > 0 {
imported["file_blobs"] = blobCount
}
}
// 13. Settings/Prefs
var userModelSettings []models.UserModelSetting
if err := ar.ReadEntityJSON("user_settings", &userModelSettings); err == nil && len(userModelSettings) > 0 {
if remapUserID {
for i := range userModelSettings {
if userModelSettings[i].UserID == sourceUserID {
userModelSettings[i].UserID = userID
}
}
}
importEntity("user_settings", func() (int, int, error) {
return h.stores.Export.ImportUserModelSettings(ctx, userModelSettings)
})
}
var notifPrefs []models.NotificationPreference
if err := ar.ReadEntityJSON("notification_preferences", &notifPrefs); err == nil && len(notifPrefs) > 0 {
if remapUserID {
for i := range notifPrefs {
if notifPrefs[i].UserID == sourceUserID {
notifPrefs[i].UserID = userID
}
}
}
importEntity("notification_preferences", func() (int, int, error) {
return h.stores.Export.ImportNotifPrefs(ctx, notifPrefs)
})
}
// 14. Persona groups
var personaGroups []models.PersonaGroup
if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 {
if remapUserID {
for i := range personaGroups {
if personaGroups[i].OwnerID == sourceUserID {
personaGroups[i].OwnerID = userID
}
}
}
importEntity("persona_groups", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroups(ctx, personaGroups)
})
}
var personaGroupMembers []models.PersonaGroupMember
if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 {
importEntity("persona_group_members", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers)
})
}
// ── Audit log ──
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.import",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors},
})
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"errors": errors,
})
}
// ImportTeam imports team data from a .switchboard archive.
// POST /api/v1/admin/teams/:id/import
func (h *DataImportHandler) ImportTeam(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
teamID := c.Param("id")
// Verify team exists
team, err := h.stores.Teams.GetByID(ctx, teamID)
if err != nil || team == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Receive upload
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
if header.Size > export.MaxExportArchiveSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
})
return
}
tmp, err := os.CreateTemp("", "switchboard-team-import-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
ar, err := export.OpenArchive(tmp.Name())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
return
}
defer ar.Close()
manifest, err := ar.ReadManifest()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()})
return
}
if manifest.FormatVersion > export.FormatVersion {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("unsupported format version %d", manifest.FormatVersion),
})
return
}
imported := make(map[string]int)
skipped := make(map[string]int)
var errors []string
importEntity := func(name string, fn func() (int, int, error)) {
imp, skip, err := fn()
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
slog.Error("import: team entity error", "entity", name, "error", err)
}
if imp > 0 {
imported[name] = imp
}
if skip > 0 {
skipped[name] = skip
}
}
// Remap team_id on entities to target team
sourceTeamID := ""
if manifest.Scope != nil {
sourceTeamID = manifest.Scope.TeamID
}
remapTeam := sourceTeamID != "" && sourceTeamID != teamID
// Channels (remap team_id)
var channels []models.Channel
if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 {
if remapTeam {
for i := range channels {
if channels[i].TeamID != nil && *channels[i].TeamID == sourceTeamID {
channels[i].TeamID = &teamID
}
}
}
importEntity("channels", func() (int, int, error) {
return h.stores.Export.ImportChannels(ctx, channels)
})
}
// Messages
var messages []models.Message
if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 {
importEntity("messages", func() (int, int, error) {
return h.stores.Export.ImportMessages(ctx, messages)
})
}
// Projects (remap team_id)
var projects []models.Project
if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 {
if remapTeam {
for i := range projects {
if projects[i].TeamID != nil && *projects[i].TeamID == sourceTeamID {
projects[i].TeamID = &teamID
}
}
}
importEntity("projects", func() (int, int, error) {
return h.stores.Export.ImportProjects(ctx, projects)
})
}
// Persona groups (remap team_id)
var personaGroups []models.PersonaGroup
if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 {
if remapTeam {
for i := range personaGroups {
if personaGroups[i].TeamID != nil && *personaGroups[i].TeamID == sourceTeamID {
personaGroups[i].TeamID = &teamID
}
}
}
importEntity("persona_groups", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroups(ctx, personaGroups)
})
}
var personaGroupMembers []models.PersonaGroupMember
if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 {
importEntity("persona_group_members", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers)
})
}
// Audit log
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "team.import",
ResourceType: "team",
ResourceID: teamID,
Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors},
})
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"errors": errors,
})
}
// ImportChatGPT imports conversations from a ChatGPT export.
// POST /api/v1/import/chatgpt
func (h *DataImportHandler) ImportChatGPT(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
if header.Size > export.MaxExportArchiveSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
})
return
}
// Detect if it's a zip or raw JSON
var convs export.ChatGPTExport
if strings.HasSuffix(header.Filename, ".zip") {
// Save to temp and extract conversations.json
tmp, err := os.CreateTemp("", "chatgpt-import-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
ar, err := export.OpenArchive(tmp.Name())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer ar.Close()
// Try to find conversations.json in the zip
if err := ar.ReadEntityJSON("conversations", &convs); err != nil || len(convs) == 0 {
// Try searching all files for conversations.json
for _, f := range ar.FileEntries() {
if strings.HasSuffix(f.Name, "conversations.json") {
rc, err := f.Open()
if err != nil {
continue
}
convs, err = export.ParseChatGPTExport(rc)
rc.Close()
if err == nil {
break
}
}
}
}
} else {
// Raw JSON file
convs, err = export.ParseChatGPTExport(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse conversations.json: " + err.Error()})
return
}
}
if len(convs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no conversations found in upload"})
return
}
// Convert to Switchboard models
result := export.ConvertChatGPTExport(convs, userID)
// Import channels and messages
chImported, chSkipped, err := h.stores.Export.ImportChannels(ctx, result.Channels)
if err != nil {
slog.Error("chatgpt import: channels", "error", err)
}
msgImported, msgSkipped, err := h.stores.Export.ImportMessages(ctx, result.Messages)
if err != nil {
slog.Error("chatgpt import: messages", "error", err)
}
imported := map[string]int{
"channels": chImported,
"messages": msgImported,
}
skipped := map[string]int{
"channels": chSkipped,
"messages": msgSkipped,
}
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.import_chatgpt",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{
"conversations": len(convs),
"imported": imported,
"skipped": skipped,
},
})
c.JSON(http.StatusOK, gin.H{
"conversations": len(convs),
"imported": imported,
"skipped": skipped,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,265 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"switchboard-core/compaction"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/roles"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
"switchboard-core/treepath"
)
// ═══════════════════════════════════════════
// Live Compaction Tests
// ═══════════════════════════════════════════
// Requires: TEST_DATABASE_URL + PROVIDER_KEY (or VENICE_API_KEY)
//
// Tests the full Compact() pipeline end-to-end:
// seed messages → call utility model → verify summary tree node
// ═══════════════════════════════════════════
// dialectStores returns the correct store bundle for the active dialect.
func dialectStores() store.Stores {
if database.IsSQLite() {
return sqlite.NewStores(database.TestDB)
}
return postgres.NewStores(database.TestDB)
}
func TestLive_CompactionFullPipeline(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
userID, adminToken := h.createAdminUser("compactadmin", "compact@test.com")
// Set up provider + enable first model
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
// Configure utility role
w := h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
"primary": map[string]interface{}{
"provider_config_id": configID,
"model_id": modelID,
},
})
if w.Code != http.StatusOK {
t.Fatalf("configure utility role: %d: %s", w.Code, w.Body.String())
}
t.Log(" ✓ Utility role configured with", modelID)
// Create a channel with enough messages to summarize
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Compaction Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Seed messages manually (not via completion — faster and cheaper)
msgs := []struct{ role, content string }{
{"user", "I'm planning a trip to Japan next month. What cities should I visit?"},
{"assistant", "For a Japan trip, I'd recommend Tokyo for modern culture, Kyoto for temples and traditional culture, Osaka for food, and Hiroshima for history. How long is your trip?"},
{"user", "About 10 days. I'm most interested in food and temples."},
{"assistant", "With 10 days focusing on food and temples, I'd suggest: 3 days in Kyoto for Fushimi Inari, Kinkaku-ji, and Arashiyama. 2 days in Osaka for Dotonbori street food, Kuromon Market, and Namba. 2 days in Tokyo for Tsukiji Outer Market, Senso-ji, and Meiji Shrine. Then day trips to Nara (deer park and Todai-ji) and Kamakura (Great Buddha)."},
{"user", "That sounds great! What about budget? I'm trying to keep it under $3000 for the whole trip."},
{"assistant", "A $3000 budget for 10 days in Japan is doable. Rough breakdown: Flights ~$800-1000, Accommodation ~$50-80/night in hostels or budget hotels ($500-800), JR Pass 14-day ~$380, Food ~$30-50/day ($300-500), Activities and temples ~$200. Total: $2180-2880. Tips: eat at konbini (convenience stores) for cheap meals, visit free shrines, and book accommodation early."},
{"user", "Should I get a JR Pass or just buy individual tickets?"},
{"assistant", "For your itinerary covering Tokyo, Kyoto, Osaka, Nara, and Kamakura, a 7-day JR Pass ($200) would cover most of your intercity travel. The Tokyo-Kyoto shinkansen alone costs $120 one-way. I'd recommend the 7-day pass starting when you leave Tokyo for Kyoto, then use IC cards (Suica/Pasmo) for local transit."},
{"user", "Perfect. One more question — what's the best time to visit temples to avoid crowds?"},
{"assistant", "Early morning is best for temples. Fushimi Inari at sunrise (5-6am) is magical and nearly empty. Kinkaku-ji opens at 9am — arrive right at opening. Arashiyama bamboo grove is best before 8am. For Senso-ji in Tokyo, go at dawn for beautiful photos. Weekdays are always less crowded than weekends."},
}
ph := database.PH
var lastMsgID string
for _, m := range msgs {
var parentPtr *string
if lastMsgID != "" {
parentPtr = &lastMsgID
}
err := database.TestDB.QueryRow(
"INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id",
channelID, parentPtr, m.role, m.content).Scan(&lastMsgID)
if err != nil {
t.Fatalf("seed message: %v", err)
}
}
// Set cursor to last message
database.TestDB.Exec(
"INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id",
channelID, userID, lastMsgID)
t.Logf(" ✓ Seeded %d messages in channel %s", len(msgs), channelID)
// ── Run compaction ──
stores := dialectStores()
resolver := roles.NewResolver(stores, nil)
svc := compaction.NewService(stores, resolver)
result, err := svc.Compact(context.Background(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: nil,
Trigger: "manual",
})
if err != nil {
t.Fatalf("Compact() failed: %v", err)
}
t.Logf(" ✓ Compact result: %d messages summarized, model=%s, %d chars",
result.SummarizedCount, result.Model, len(result.Content))
// Verify summary was created
if result.SummarizedCount != len(msgs) {
t.Errorf("summarized_count = %d, want %d", result.SummarizedCount, len(msgs))
}
if result.Content == "" {
t.Fatal("summary content should not be empty")
}
if result.Model != modelID {
t.Errorf("model = %q, want %q", result.Model, modelID)
}
// Verify summary message exists in the tree
var summaryContent, summaryRole string
var metadataRaw []byte
err = database.TestDB.QueryRow(
"SELECT role, content, metadata FROM messages WHERE id = "+ph(1),
result.SummaryID).Scan(&summaryRole, &summaryContent, &metadataRaw)
if err != nil {
t.Fatalf("query summary message: %v", err)
}
if summaryRole != "assistant" {
t.Errorf("summary role = %q, want assistant", summaryRole)
}
var metadata models.JSONMap
json.Unmarshal(metadataRaw, &metadata)
if metadata["type"] != "summary" {
t.Errorf("metadata.type = %q, want summary", metadata["type"])
}
if metadata["trigger"] != "manual" {
t.Errorf("metadata.trigger = %q, want manual", metadata["trigger"])
}
t.Logf(" ✓ Summary message verified: id=%s, metadata=%v", result.SummaryID, metadata)
// Verify cursor was updated to point to summary
path, err := treepath.GetActivePath(channelID, userID)
if err != nil {
t.Fatalf("GetActivePath after compaction: %v", err)
}
if len(path) == 0 {
t.Fatal("path should not be empty after compaction")
}
lastPath := path[len(path)-1]
if lastPath.ID != result.SummaryID {
t.Errorf("active path leaf = %s, want summary %s", lastPath.ID, result.SummaryID)
}
t.Logf(" ✓ Cursor updated: path has %d messages, leaf is summary", len(path))
// Verify usage was logged
var usageCount int
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM usage_log WHERE channel_id = "+ph(1)+" AND role = 'utility'",
channelID).Scan(&usageCount)
if usageCount == 0 {
t.Error("expected usage_log entry for utility role")
}
t.Logf(" ✓ Usage logged: %d entries", usageCount)
}
// TestLive_CompactionContextBudgetGuardRail verifies that Compact()
// rejects input that exceeds the utility model's context window.
func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("guardrail_admin", "guardrail@test.com")
userID := database.SeedTestUser(t, "guardrail_user", "gruser@test.com")
// Set up provider + enable first model
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
ph := database.PH
// Set max_context to 32000 in catalog (in case sync didn't populate it)
if database.IsSQLite() {
database.TestDB.Exec(
"UPDATE model_catalog SET capabilities = json_set(COALESCE(capabilities,'{}'), '$.max_context', 32000) WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2),
configID, modelID)
} else {
database.TestDB.Exec(
"UPDATE model_catalog SET capabilities = capabilities || '{\"max_context\": 32000}'::jsonb WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2),
configID, modelID)
}
// Configure utility role
h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
"primary": map[string]interface{}{
"provider_config_id": configID,
"model_id": modelID,
},
})
// Create channel with huge messages (>32K context worth)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Guard Rail Test", "type": "direct",
})
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Seed 20 messages × 8000 chars each = 160K chars ≈ 40K tokens
bigContent := make([]byte, 8000)
for i := range bigContent {
bigContent[i] = 'a'
}
var lastMsgID string
for i := 0; i < 20; i++ {
var parentPtr *string
if lastMsgID != "" {
parentPtr = &lastMsgID
}
role := "user"
if i%2 == 1 {
role = "assistant"
}
database.TestDB.QueryRow(
"INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id",
channelID, parentPtr, role, string(bigContent)).Scan(&lastMsgID)
}
database.TestDB.Exec(
"INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id",
channelID, userID, lastMsgID)
// Run compaction — should fail with context budget error
stores := dialectStores()
resolver := roles.NewResolver(stores, nil)
svc := compaction.NewService(stores, resolver)
_, err := svc.Compact(context.Background(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: nil,
Trigger: "auto",
})
if err == nil {
t.Fatal("Compact() should fail when content exceeds utility model context window")
}
t.Logf(" ✓ Guard rail triggered: %v", err)
if !strings.Contains(err.Error(), "context window") {
t.Errorf("expected context window error, got: %v", err)
}
}

View File

@@ -1,812 +0,0 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"switchboard-core/database"
"switchboard-core/providers"
)
// ═══════════════════════════════════════════
// Live Provider Integration Tests
// ═══════════════════════════════════════════
// These tests require:
// - TEST_DATABASE_URL or PGHOST+PGUSER
// - PROVIDER + PROVIDER_KEY (or legacy VENICE_API_KEY)
//
// Provider config env vars:
// PROVIDER — provider type: "venice", "openai", "anthropic" (default: "venice")
// PROVIDER_KEY — API key (falls back to VENICE_API_KEY for compat)
// PROVIDER_URL — endpoint override (optional, uses default for known providers)
//
// They exercise the full flow: create provider →
// fetch models → enable model → resolve → complete.
// ═══════════════════════════════════════════
// liveProviderConfig holds resolved provider settings for live tests.
type liveProviderConfig struct {
Provider string // "venice", "openai", "anthropic"
Key string
Endpoint string
}
// defaultEndpoints maps provider names to their default API endpoints.
var defaultEndpoints = map[string]string{
"venice": "https://api.venice.ai/api/v1",
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com/v1",
"openrouter": "https://openrouter.ai/api/v1",
}
// providerKeyEnvs maps provider names to their API key env var names.
var providerKeyEnvs = map[string]string{
"venice": "VENICE_API_KEY",
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
}
// requireLiveProvider resolves the primary provider config from env vars.
// Kept for backward compat — tests that only need one provider use this.
func requireLiveProvider(t *testing.T) liveProviderConfig {
t.Helper()
key := os.Getenv("PROVIDER_KEY")
if key == "" {
// Legacy fallback
key = os.Getenv("VENICE_API_KEY")
}
if key == "" {
t.Skip("PROVIDER_KEY (or VENICE_API_KEY) not set — skipping live provider test")
}
provider := os.Getenv("PROVIDER")
if provider == "" {
provider = "venice" // default
}
endpoint := os.Getenv("PROVIDER_URL")
if endpoint == "" {
var ok bool
endpoint, ok = defaultEndpoints[provider]
if !ok {
t.Fatalf("PROVIDER=%q has no default endpoint — set PROVIDER_URL", provider)
}
}
t.Logf(" Live provider: %s @ %s", provider, endpoint)
return liveProviderConfig{Provider: provider, Key: key, Endpoint: endpoint}
}
// requireLiveProviders resolves all configured providers for failover tests.
// Reads LIVE_PROVIDERS (comma-separated, e.g. "venice,openai") and resolves
// API keys from {PROVIDER}_API_KEY env vars. Falls back to requireLiveProvider
// if LIVE_PROVIDERS is not set.
func requireLiveProviders(t *testing.T) []liveProviderConfig {
t.Helper()
list := os.Getenv("LIVE_PROVIDERS")
if list == "" {
// Fallback: just the primary provider
return []liveProviderConfig{requireLiveProvider(t)}
}
var configs []liveProviderConfig
for _, name := range splitTrim(list, ",") {
keyEnv := providerKeyEnvs[name]
if keyEnv == "" {
keyEnv = strings.ToUpper(name) + "_API_KEY"
}
key := os.Getenv(keyEnv)
if key == "" {
t.Logf(" Skipping provider %s: %s not set", name, keyEnv)
continue
}
endpoint := os.Getenv(strings.ToUpper(name) + "_API_URL")
if endpoint == "" {
endpoint = defaultEndpoints[name]
}
if endpoint == "" {
t.Logf(" Skipping provider %s: no endpoint", name)
continue
}
configs = append(configs, liveProviderConfig{Provider: name, Key: key, Endpoint: endpoint})
}
if len(configs) == 0 {
t.Skip("no live providers configured — set LIVE_PROVIDERS + API keys")
}
t.Logf(" Live providers: %d configured", len(configs))
return configs
}
// splitTrim splits s by sep and trims whitespace from each element.
func splitTrim(s, sep string) []string {
parts := strings.Split(s, sep)
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// providerModel holds a resolved (configID, modelID) pair from a provider.
type providerModel struct {
ConfigID string
ModelID string
Provider string
}
// setupAllProviders creates configs and enables a model for each provider.
// Tolerant: if a provider fails setup, it's skipped. Fails only if zero succeed.
func setupAllProviders(t *testing.T, h *testHarness, adminToken string, providerList []liveProviderConfig) []providerModel {
t.Helper()
var models []providerModel
for _, pc := range providerList {
configID, modelID, err := trySetupProvider(h, adminToken, pc)
if err != nil {
t.Logf(" ⚠ %s setup failed: %v — skipping", pc.Provider, err)
continue
}
t.Logf(" Provider %s ready, model %s enabled", pc.Provider, modelID)
models = append(models, providerModel{ConfigID: configID, ModelID: modelID, Provider: pc.Provider})
}
if len(models) == 0 {
t.Fatal("no providers available after setup — all failed")
}
return models
}
// trySetupProvider is like setupProviderWithModel but returns error instead of t.Fatal.
func trySetupProvider(h *testHarness, adminToken string, pc liveProviderConfig) (string, string, error) {
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
if w.Code != http.StatusCreated {
return "", "", fmt.Errorf("create config: %d: %s", w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch models
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
if w.Code != http.StatusOK {
return "", "", fmt.Errorf("fetch models: %d: %s", w.Code, w.Body.String())
}
// Find and enable a non-reasoning model
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
var catalogID, modelID string
var fallbackCatalogID, fallbackModelID string
var toolCapableCatalogID, toolCapableModelID string
for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
}
// Only pick models from this provider
if m["provider_config_id"] != nil && m["provider_config_id"].(string) != configID {
continue
}
mid := m["model_id"].(string)
cid := m["id"].(string)
if fallbackCatalogID == "" {
fallbackCatalogID = cid
fallbackModelID = mid
}
isReasoning := false
hasToolCalling := false
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
if r, exists := caps["reasoning"]; exists && r == true {
isReasoning = true
}
if tc, exists := caps["tool_calling"]; exists && tc == true {
hasToolCalling = true
}
}
// Prefer non-reasoning models with tool_calling support
if !isReasoning && hasToolCalling && toolCapableCatalogID == "" {
toolCapableCatalogID = cid
toolCapableModelID = mid
}
if !isReasoning && catalogID == "" {
catalogID = cid
modelID = mid
}
}
// Prefer tool-capable model (server may inject tools into completion)
if toolCapableCatalogID != "" {
catalogID = toolCapableCatalogID
modelID = toolCapableModelID
}
if catalogID == "" {
catalogID = fallbackCatalogID
modelID = fallbackModelID
}
if catalogID == "" {
return "", "", fmt.Errorf("no disabled model found")
}
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
return "", "", fmt.Errorf("enable model: %d: %s", w.Code, w.Body.String())
}
return configID, modelID, nil
}
// tryCompletion attempts a completion against each provider/model in order.
// Returns the first successful response and the provider that worked.
// Fails only if ALL providers fail.
func tryCompletion(t *testing.T, h *testHarness, token, channelID string, models []providerModel, stream bool) (*httptest.ResponseRecorder, providerModel) {
t.Helper()
var lastW *httptest.ResponseRecorder
for _, pm := range models {
w := h.request("POST", "/api/v1/chat/completions", token, map[string]interface{}{
"channel_id": channelID,
"content": "Say ok",
"model": pm.ModelID,
"provider_config_id": pm.ConfigID,
"stream": &stream,
"max_tokens": 1200,
})
if w.Code == http.StatusOK {
t.Logf(" ✓ Completion via %s/%s (status %d)", pm.Provider, pm.ModelID, w.Code)
return w, pm
}
t.Logf(" ✗ %s/%s returned %d — trying next", pm.Provider, pm.ModelID, w.Code)
lastW = w
}
// All failed
body := ""
if lastW != nil {
body = lastW.Body.String()
}
t.Fatalf("all %d providers failed; last: %s", len(models), body)
return nil, providerModel{} // unreachable
}
// setupProviderWithModel creates a provider config, fetches models, and enables
// the first available model. Returns (configID, enabledModelID).
func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc liveProviderConfig) (string, string) {
t.Helper()
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
if w.Code != http.StatusCreated {
t.Fatalf("create %s config: want 201, got %d: %s", pc.Provider, w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch models
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: %d: %s", w.Code, w.Body.String())
}
// Find and enable a model.
// Prefer non-reasoning models: they're cheaper and don't require
// minimum thinking budget tokens (which causes 400s with low max_tokens).
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
var catalogID, modelID string
var fallbackCatalogID, fallbackModelID string
for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
}
mid := m["model_id"].(string)
cid := m["id"].(string)
// Save first disabled model as fallback
if fallbackCatalogID == "" {
fallbackCatalogID = cid
fallbackModelID = mid
}
// Check if this is a reasoning model — skip it if possible
isReasoning := false
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
if r, exists := caps["reasoning"]; exists && r == true {
isReasoning = true
}
}
if !isReasoning {
catalogID = cid
modelID = mid
break
}
}
// Fall back to any disabled model if all are reasoning models
if catalogID == "" {
catalogID = fallbackCatalogID
modelID = fallbackModelID
}
if catalogID == "" {
t.Fatal("no disabled model found to enable after fetch")
}
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: %d: %s", w.Code, w.Body.String())
}
t.Logf(" Provider %s ready, model %s enabled", configID, modelID)
return configID, modelID
}
// TestLive_ProviderFullFlow exercises the complete admin workflow:
// create provider → fetch models → enable a model → user sees it → chat completion
func TestLive_ProviderFullFlow(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// ── 1. Create provider config ────────────
t.Log("Step 1: Creating provider config")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Live Test",
"provider": pc.Provider,
"endpoint": pc.Endpoint,
"api_key": pc.Key,
})
if w.Code != http.StatusCreated {
t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
}
var configResp map[string]interface{}
decode(w, &configResp)
configID := configResp["id"].(string)
t.Logf(" Created config: %s", configID)
// ── 2. Fetch models ─────────────────────
t.Log("Step 2: Fetching models from provider API")
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{
"provider_config_id": configID,
})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetchResp map[string]interface{}
decode(w, &fetchResp)
totalFetched := fetchResp["total"].(float64)
if totalFetched < 1 {
t.Fatalf("provider should return at least 1 model, got %.0f", totalFetched)
}
t.Logf(" Fetched %.0f models", totalFetched)
// ── 3. List + enable first model ────────
t.Log("Step 3: Enabling first available model")
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
catalogModels := modelsResp["data"].([]interface{})
var enableID, enableModelID string
var fallbackID, fallbackModelID string
for _, raw := range catalogModels {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
}
mid := m["model_id"].(string)
cid := m["id"].(string)
if fallbackID == "" {
fallbackID = cid
fallbackModelID = mid
}
// Prefer non-reasoning models (cheaper, no thinking budget requirement)
isReasoning := false
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
if r, exists := caps["reasoning"]; exists && r == true {
isReasoning = true
}
}
if !isReasoning {
enableID = cid
enableModelID = mid
break
}
}
if enableID == "" {
enableID = fallbackID
enableModelID = fallbackModelID
}
if enableID == "" {
t.Fatal("no disabled model found to enable")
}
t.Logf(" Enabling: %s (catalog ID: %s)", enableModelID, enableID)
w = h.request("PUT", "/api/v1/admin/models/"+enableID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── 4. Admin sees enabled model ─────────
t.Log("Step 4: Verifying models/enabled (admin)")
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
var enabledResp map[string]interface{}
decode(w, &enabledResp)
enabledModels := enabledResp["data"].([]interface{})
if len(enabledModels) < 1 {
t.Fatal("models/enabled should return at least 1 model")
}
found := false
for _, raw := range enabledModels {
m := raw.(map[string]interface{})
if m["model_id"] == enableModelID {
found = true
if m["config_id"] == nil || m["config_id"] == "" {
t.Error("enabled model must have config_id")
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Error("enabled model must have provider_name")
}
break
}
}
if !found {
t.Errorf("model %s not found in models/enabled", enableModelID)
}
// ── 5. Regular user also sees model ─────
t.Log("Step 5: Verifying models/enabled (regular user)")
userID := database.SeedTestUser(t, "liveuser", "liveuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(1), userID)
userToken := makeToken(userID, "liveuser@test.com", "user")
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
var userResp map[string]interface{}
decode(w, &userResp)
userModels := userResp["data"].([]interface{})
if len(userModels) < 1 {
t.Fatal("regular user should see at least 1 enabled model")
}
}
// TestLive_FetchModelsCapabilities verifies that model capabilities
// are correctly parsed into the catalog.
func TestLive_FetchModelsCapabilities(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Caps Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
for _, raw := range resp["data"].([]interface{}) {
m := raw.(map[string]interface{})
caps, ok := m["capabilities"].(map[string]interface{})
if !ok {
t.Errorf("model %s: capabilities must be an object", m["model_id"])
continue
}
// streaming should be true for most providers
if caps["streaming"] != true {
t.Logf(" note: model %s streaming=%v", m["model_id"], caps["streaming"])
}
// Verify capabilities are actual booleans
for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} {
if v, exists := caps[key]; exists {
if _, ok := v.(bool); !ok {
t.Errorf("model %s: capability %s should be bool, got %T", m["model_id"], key, v)
}
}
}
}
}
// TestLive_ChatCompletion sends an actual non-streaming chat completion.
func TestLive_ChatCompletion(t *testing.T) {
h := setupHarness(t)
liveProvs := requireLiveProviders(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
models := setupAllProviders(t, h, adminToken, liveProvs)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Chat Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
w, _ = tryCompletion(t, h, adminToken, channelID, models, false)
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
}
// TestLive_UsageLogging verifies that a non-streaming completion
// creates a usage_log row with token counts.
func TestLive_UsageLogging(t *testing.T) {
h := setupHarness(t)
liveProvs := requireLiveProviders(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
models := setupAllProviders(t, h, adminToken, liveProvs)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Usage Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
_, used := tryCompletion(t, h, adminToken, ch["id"].(string), models, false)
var rowCount, promptTokens, completionTokens int
err := database.TestDB.QueryRow(
"SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1),
used.ConfigID).Scan(&rowCount, &promptTokens, &completionTokens)
if err != nil {
t.Fatalf("query usage_log: %v", err)
}
if rowCount == 0 {
t.Fatal("usage_log should have a row after completion")
}
if promptTokens == 0 {
t.Fatal("completion should report prompt tokens")
}
t.Logf(" ✓ Usage: %d row(s), prompt=%d completion=%d (via %s)", rowCount, promptTokens, completionTokens, used.Provider)
}
// TestLive_StreamingUsageLogging verifies streaming completions log usage.
func TestLive_StreamingUsageLogging(t *testing.T) {
h := setupHarness(t)
liveProvs := requireLiveProviders(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
models := setupAllProviders(t, h, adminToken, liveProvs)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Stream Usage Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
_, used := tryCompletion(t, h, adminToken, ch["id"].(string), models, true)
var rowCount, promptTokens, completionTokens int
err := database.TestDB.QueryRow(
"SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1),
used.ConfigID).Scan(&rowCount, &promptTokens, &completionTokens)
if err != nil {
t.Fatalf("query usage_log: %v", err)
}
if rowCount == 0 {
t.Fatal("usage_log should have a row after streaming completion")
}
if promptTokens == 0 {
// Some providers (e.g. Venice) don't report token usage in streaming responses
t.Skipf("streaming completion did not report prompt tokens (provider: %s) — some providers omit streaming usage", used.Provider)
}
t.Logf(" ✓ Streaming usage: %d row(s), prompt=%d completion=%d (via %s)", rowCount, promptTokens, completionTokens, used.Provider)
}
// TestLive_PricingFromCatalog verifies model sync populates pricing.
func TestLive_PricingFromCatalog(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
configID, _ := setupProviderWithModel(t, h, adminToken, pc)
var pricingCount int
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = "+database.PH(1),
configID).Scan(&pricingCount)
if pricingCount == 0 {
t.Skip("model sync did not populate pricing — provider may not support pricing data")
}
t.Logf(" ✓ Catalog sync populated %d pricing entries", pricingCount)
w := h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin pricing: %d: %s", w.Code, w.Body.String())
}
var entries []interface{}
decode(w, &entries)
if len(entries) == 0 {
t.Error("admin pricing API should return catalog-synced entries")
}
t.Logf(" ✓ Admin pricing API returns %d entries", len(entries))
}
// TestLive_Embeddings tests the embeddings endpoint directly.
// Only runs for providers that support embeddings (currently Venice).
func TestLive_Embeddings(t *testing.T) {
pc := requireLiveProvider(t)
// Only Venice has a known embedding model for now
if pc.Provider != "venice" {
t.Skipf("embedding test only implemented for Venice (got %s)", pc.Provider)
}
provider := &providers.VeniceProvider{}
cfg := providers.ProviderConfig{
Endpoint: pc.Endpoint,
APIKey: pc.Key,
}
// Retry up to 3 times — Venice embedding endpoint can return transient 500s
var resp *providers.EmbeddingResponse
var err error
for attempt := 1; attempt <= 3; attempt++ {
resp, err = provider.Embed(
context.Background(), cfg,
providers.EmbeddingRequest{
Model: "text-embedding-bge-m3",
Input: []string{"test embedding"},
},
)
if err == nil {
break
}
t.Logf(" Embed attempt %d: %v", attempt, err)
if attempt < 3 {
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
}
if err != nil {
t.Skipf("Embed failed after 3 attempts (transient): %v", err)
}
if len(resp.Embeddings) == 0 || len(resp.Embeddings[0]) == 0 {
t.Fatal("expected non-empty embedding vector")
}
t.Logf(" ✓ Embedding: %d dimensions, input_tokens=%d",
len(resp.Embeddings[0]), resp.InputTokens)
}
// TestLive_ModelDeletion tests cleanup: delete provider removes catalog entries.
func TestLive_ModelDeletion(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Delete Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
var count int
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1),
configID).Scan(&count)
if count == 0 {
t.Fatal("catalog should have models after fetch")
}
t.Logf(" %d models before delete", count)
w = h.request("DELETE", "/api/v1/admin/configs/"+configID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
}
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1),
configID).Scan(&count)
if count != 0 {
t.Errorf("catalog should be empty after delete, got %d", count)
}
t.Log(" ✓ Cascade delete cleaned up catalog entries")
}
// TestLive_BYOK_AutoFetch exercises the user experience:
// user creates a BYOK provider → auto-fetch triggers → models appear.
func TestLive_BYOK_AutoFetch(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Enable BYOK policy
h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
map[string]interface{}{"value": "true"})
userID := database.SeedTestUser(t, "byokuser", "byokuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(1), userID)
userToken := makeToken(userID, "byokuser@test.com", "user")
// User creates BYOK provider
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
"name": "My " + pc.Provider,
"provider": pc.Provider,
"endpoint": pc.Endpoint,
"api_key": pc.Key,
})
if w.Code != http.StatusCreated {
t.Fatalf("create BYOK: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
cfgID := created["id"].(string)
if created["warning"] != nil {
t.Fatalf("auto-fetch should succeed, got warning: %v", created["warning"])
}
modelsFetched := created["models_fetched"]
if modelsFetched == nil || modelsFetched.(float64) < 1 {
t.Fatalf("auto-fetch should return models_fetched > 0, got: %v", modelsFetched)
}
t.Logf(" Auto-fetched %.0f models", modelsFetched.(float64))
// Verify user sees personal models
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
var resp map[string]interface{}
decode(w, &resp)
userModels := resp["data"].([]interface{})
personalCount := 0
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["scope"] == "personal" {
personalCount++
}
}
if personalCount < 1 {
t.Fatalf("user should see personal BYOK models, got %d", personalCount)
}
t.Logf(" ✓ User sees %d personal BYOK models", personalCount)
// Cleanup
h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
}

View File

@@ -1,297 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/memory"
"switchboard-core/models"
"switchboard-core/store"
)
// MemoryHandler provides REST endpoints for memory management.
type MemoryHandler struct {
stores store.Stores
compactor *memory.Compactor
}
// NewMemoryHandler creates a new memory handler.
func NewMemoryHandler(stores store.Stores) *MemoryHandler {
return &MemoryHandler{stores: stores}
}
// SetCompactor wires the compactor for the /compact endpoint.
func (h *MemoryHandler) SetCompactor(c *memory.Compactor) {
h.compactor = c
}
// ListMyMemories returns the current user's memories with optional filters.
// GET /api/v1/memories?status=active&query=...
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
status := c.DefaultQuery("status", "active")
query := c.Query("query")
memories, err := h.stores.Memories.List(c.Request.Context(), models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: status,
Query: query,
Limit: 100,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if memories == nil {
memories = []models.Memory{}
}
c.JSON(http.StatusOK, gin.H{"data": memories})
}
// UpdateMemory updates a memory's key/value/confidence.
// PUT /api/v1/memories/:id
func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
// Ownership check: user can only edit their own user-scope memories
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
var body struct {
Key *string `json:"key"`
Value *string `json:"value"`
Confidence *float64 `json:"confidence"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if body.Key != nil {
existing.Key = *body.Key
}
if body.Value != nil {
existing.Value = *body.Value
}
if body.Confidence != nil {
existing.Confidence = *body.Confidence
}
if err := h.stores.Memories.Update(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memory": existing})
}
// DeleteMemory hard-deletes a memory.
// DELETE /api/v1/memories/:id
func (h *MemoryHandler) DeleteMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
if err := h.stores.Memories.Delete(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ApproveMemory transitions a pending_review memory to active.
// POST /api/v1/memories/:id/approve
func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
if existing.Status != models.MemoryStatusPendingReview {
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be approved"})
return
}
existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memory": existing})
}
// RejectMemory archives a pending_review memory.
// POST /api/v1/memories/:id/reject
func (h *MemoryHandler) RejectMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
// Ownership check: user can only reject their own user-scope memories
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
if existing.Status != models.MemoryStatusPendingReview {
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be rejected"})
return
}
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rejected": true})
}
// MemoryCount returns active + pending counts for the current user.
// GET /api/v1/memories/count
func (h *MemoryHandler) MemoryCount(c *gin.Context) {
userID := c.GetString("user_id")
ctx := c.Request.Context()
active, err := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Count pending by listing
pending, err := h.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: models.MemoryStatusPendingReview,
Limit: 1000,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"active": active,
"pending": len(pending),
})
}
// ── Admin Endpoints ──────────────────────────
// ListPendingReview returns all pending_review memories (admin only).
// GET /api/v1/admin/memories/pending
func (h *MemoryHandler) ListPendingReview(c *gin.Context) {
ctx := c.Request.Context()
memories, err := h.stores.Memories.ListByStatus(ctx, models.MemoryStatusPendingReview, 200)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if memories == nil {
memories = []models.Memory{}
}
c.JSON(http.StatusOK, gin.H{"data": memories})
}
// BulkApprove approves multiple memories at once.
// POST /api/v1/admin/memories/bulk-approve
func (h *MemoryHandler) BulkApprove(c *gin.Context) {
var body struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
ctx := c.Request.Context()
approved := 0
for _, id := range body.IDs {
existing, err := h.stores.Memories.GetByID(ctx, id)
if err != nil {
continue
}
if existing.Status != models.MemoryStatusPendingReview {
continue
}
existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
approved++
}
}
c.JSON(http.StatusOK, gin.H{"approved": approved})
}
// CompactMemories triggers memory compaction for the current user.
// POST /api/v1/memories/compact
func (h *MemoryHandler) CompactMemories(c *gin.Context) {
if h.compactor == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compaction not available"})
return
}
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
teamID := c.GetString("team_id")
var tID *string
if teamID != "" {
tID = &teamID
}
result, err := h.compactor.Compact(c.Request.Context(), userID, tID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"before": result.Before,
"after": result.After,
"merged": result.Merged,
"archived": result.Archived,
})
}

View File

@@ -1,100 +0,0 @@
package handlers
import (
"context"
"fmt"
"log"
"strings"
"switchboard-core/knowledge"
"switchboard-core/models"
"switchboard-core/store"
)
// maxMemoryChars is the approximate character budget for injected memories.
const maxMemoryChars = 6000
// BuildMemoryHint loads active memories for the user (and persona if active)
// and formats them as a system prompt injection.
//
// Phase 2: if an embedder is available and the user's latest message
// is provided, it generates a query vector for semantic recall. Otherwise
// falls back to keyword/confidence ranking.
func BuildMemoryHint(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID, personaID, lastUserMessage string) string {
if stores.Memories == nil {
return ""
}
var pID *string
if personaID != "" {
pID = &personaID
}
var memories []models.Memory
var err error
// Try hybrid recall with embedding if available
if embedder != nil && embedder.IsConfigured(ctx) && lastUserMessage != "" {
memories, err = hybridRecallForInjection(ctx, stores, embedder, userID, pID, lastUserMessage)
}
// Fall back to keyword recall
if err != nil || len(memories) == 0 {
memories, err = stores.Memories.Recall(ctx, userID, pID, "", 30)
}
if err != nil {
log.Printf("⚠ memory injection failed: %v", err)
return ""
}
if len(memories) == 0 {
return ""
}
// Format memories as bullet points, respecting token budget
var b strings.Builder
b.WriteString("Known facts about this user (from previous conversations):\n")
totalChars := b.Len()
included := 0
for _, m := range memories {
line := fmt.Sprintf("• %s: %s\n", m.Key, m.Value)
if totalChars+len(line) > maxMemoryChars {
break
}
b.WriteString(line)
totalChars += len(line)
included++
}
if included == 0 {
return ""
}
log.Printf("🧠 Injected %d memories for user %s (persona: %s)", included, userID, personaID)
return b.String()
}
// hybridRecallForInjection generates a query vector from the user's message
// and performs hybrid semantic + keyword recall.
func hybridRecallForInjection(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID string, personaID *string, message string) ([]models.Memory, error) {
text := message
if len(text) > 2000 {
text = text[:2000]
}
var teamID *string
if stores.Teams != nil {
ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
if len(ids) > 0 {
teamID = &ids[0]
}
}
result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text})
if err != nil || len(result.Vectors) == 0 {
return nil, err
}
return stores.Memories.RecallHybrid(ctx, userID, personaID, "", result.Vectors[0], 30)
}

View File

@@ -1,435 +0,0 @@
package handlers
import (
"net/http"
"testing"
"switchboard-core/database"
"switchboard-core/models"
)
// seedMemory inserts a memory directly into the DB for testing.
func seedMemory(t *testing.T, ownerID, key, value, status string) string {
t.Helper()
id := seedID()
q := dialectSQL(`
INSERT INTO memories (id, scope, owner_id, key, value, confidence, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`)
_, err := database.TestDB.Exec(q, id, models.MemoryScopeUser, ownerID, key, value, 0.9, status)
if err != nil {
t.Fatalf("seedMemory: %v", err)
}
return id
}
// ═══════════════════════════════════════════════
// List
// ═══════════════════════════════════════════════
func TestMemory_ListEmpty(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("memuser1", "memuser1@test.com")
w := h.request("GET", "/api/v1/memories", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Must use "data" key (envelope convention)
data, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("response must have 'data' array key, got: %s", w.Body.String())
}
if len(data) != 0 {
t.Fatalf("expected 0 memories, got %d", len(data))
}
}
func TestMemory_ListWithData(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memuser2", "memuser2@test.com")
seedMemory(t, userID, "lang", "Go", models.MemoryStatusActive)
seedMemory(t, userID, "editor", "Vim", models.MemoryStatusActive)
seedMemory(t, userID, "pending-fact", "something", models.MemoryStatusPendingReview)
// Default status=active
w := h.request("GET", "/api/v1/memories", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 2 {
t.Fatalf("expected 2 active memories, got %d", len(data))
}
// Explicit status=pending_review
w = h.request("GET", "/api/v1/memories?status=pending_review", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list pending: want 200, got %d", w.Code)
}
decode(w, &resp)
data = resp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 pending memory, got %d", len(data))
}
}
func TestMemory_ListIsolation(t *testing.T) {
h := setupHarness(t)
userA, tokenA := h.createAdminUser("memA", "memA@test.com")
userB, _ := h.createAdminUser("memB", "memB@test.com")
seedMemory(t, userA, "a-fact", "user A", models.MemoryStatusActive)
seedMemory(t, userB, "b-fact", "user B", models.MemoryStatusActive)
w := h.request("GET", "/api/v1/memories", tokenA, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("user A should only see 1 memory, got %d", len(data))
}
mem := data[0].(map[string]interface{})
if mem["key"].(string) != "a-fact" {
t.Fatalf("wrong memory: got key %q", mem["key"])
}
}
// ═══════════════════════════════════════════════
// Update
// ═══════════════════════════════════════════════
func TestMemory_Update(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memupdater", "memupdater@test.com")
memID := seedMemory(t, userID, "old-key", "old-value", models.MemoryStatusActive)
w := h.request("PUT", "/api/v1/memories/"+memID, token, map[string]interface{}{
"key": "new-key",
"value": "new-value",
})
if w.Code != http.StatusOK {
t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
mem := resp["memory"].(map[string]interface{})
if mem["key"].(string) != "new-key" {
t.Fatalf("key not updated: got %q", mem["key"])
}
if mem["value"].(string) != "new-value" {
t.Fatalf("value not updated: got %q", mem["value"])
}
}
func TestMemory_UpdateOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("memowner", "memowner@test.com")
_, otherToken := h.createAdminUser("memother", "memother@test.com")
memID := seedMemory(t, ownerID, "secret", "mine", models.MemoryStatusActive)
w := h.request("PUT", "/api/v1/memories/"+memID, otherToken, map[string]interface{}{
"value": "hacked",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update other's memory: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestMemory_UpdateNotFound(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("memghost", "memghost@test.com")
w := h.request("PUT", "/api/v1/memories/"+seedID(), token, map[string]interface{}{
"value": "x",
})
if w.Code != http.StatusNotFound {
t.Fatalf("update missing: want 404, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Delete
// ═══════════════════════════════════════════════
func TestMemory_Delete(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memdeleter", "memdeleter@test.com")
memID := seedMemory(t, userID, "to-delete", "gone", models.MemoryStatusActive)
w := h.request("DELETE", "/api/v1/memories/"+memID, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify gone — list should be empty
w = h.request("GET", "/api/v1/memories", token, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 0 {
t.Fatalf("memory should be deleted, got %d", len(data))
}
}
func TestMemory_DeleteOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("delowner", "delowner@test.com")
_, otherToken := h.createAdminUser("delother", "delother@test.com")
memID := seedMemory(t, ownerID, "no-touch", "x", models.MemoryStatusActive)
w := h.request("DELETE", "/api/v1/memories/"+memID, otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("delete other's memory: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Approve
// ═══════════════════════════════════════════════
func TestMemory_Approve(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memapprover", "memapprover@test.com")
memID := seedMemory(t, userID, "pending", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("approve: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
mem := resp["memory"].(map[string]interface{})
if mem["status"].(string) != models.MemoryStatusActive {
t.Fatalf("status should be active, got %q", mem["status"])
}
}
func TestMemory_ApproveStatusGuard(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memguard1", "memguard1@test.com")
// Try to approve an already-active memory
memID := seedMemory(t, userID, "already-active", "val", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("approve active memory: want 409, got %d: %s", w.Code, w.Body.String())
}
// Try to approve an archived memory
archivedID := seedMemory(t, userID, "archived", "val", models.MemoryStatusArchived)
w = h.request("POST", "/api/v1/memories/"+archivedID+"/approve", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("approve archived memory: want 409, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Reject
// ═══════════════════════════════════════════════
func TestMemory_Reject(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memrejecter", "memrejecter@test.com")
memID := seedMemory(t, userID, "to-reject", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("reject: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
if resp["rejected"] != true {
t.Fatal("response should have rejected: true")
}
}
func TestMemory_RejectStatusGuard(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memguard2", "memguard2@test.com")
// Can't reject an already-active memory
memID := seedMemory(t, userID, "active-reject", "val", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("reject active memory: want 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestMemory_RejectOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("rejowner", "rejowner@test.com")
_, otherToken := h.createAdminUser("rejother", "rejother@test.com")
memID := seedMemory(t, ownerID, "not-yours", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("reject other's memory: want 403, got %d: %s", w.Code, w.Body.String())
}
}
// ═══════════════════════════════════════════════
// Count
// ═══════════════════════════════════════════════
func TestMemory_Count(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memcounter", "memcounter@test.com")
seedMemory(t, userID, "fact1", "a", models.MemoryStatusActive)
seedMemory(t, userID, "fact2", "b", models.MemoryStatusActive)
seedMemory(t, userID, "pend1", "c", models.MemoryStatusPendingReview)
w := h.request("GET", "/api/v1/memories/count", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("count: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Response shape: {"active": N, "pending": N}
active := int(resp["active"].(float64))
pending := int(resp["pending"].(float64))
if active != 2 {
t.Fatalf("expected 2 active, got %d", active)
}
if pending != 1 {
t.Fatalf("expected 1 pending, got %d", pending)
}
}
// ═══════════════════════════════════════════════
// Admin — List Pending
// ═══════════════════════════════════════════════
func TestMemory_AdminListPending(t *testing.T) {
h := setupHarness(t)
userA, _ := h.createAdminUser("adminmemA", "adminmemA@test.com")
userB, _ := h.createAdminUser("adminmemB", "adminmemB@test.com")
_, adminToken := h.createAdminUser("memadmin", "memadmin@test.com")
// Seed pending memories for both users
seedMemory(t, userA, "factA", "from A", models.MemoryStatusPendingReview)
seedMemory(t, userB, "factB", "from B", models.MemoryStatusPendingReview)
seedMemory(t, userA, "active-A", "active", models.MemoryStatusActive) // should NOT appear
w := h.request("GET", "/api/v1/admin/memories/pending", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin pending: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 2 {
t.Fatalf("expected 2 pending memories across users, got %d", len(data))
}
}
func TestMemory_AdminListPendingRequiresAdmin(t *testing.T) {
h := setupHarness(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("normalmem", "normalmem@test.com", "password123")
w := h.request("GET", "/api/v1/admin/memories/pending", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin should be forbidden: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Admin — Bulk Approve
// ═══════════════════════════════════════════════
func TestMemory_AdminBulkApprove(t *testing.T) {
h := setupHarness(t)
userID, _ := h.createAdminUser("bulkowner", "bulkowner@test.com")
_, adminToken := h.createAdminUser("bulkadmin", "bulkadmin@test.com")
id1 := seedMemory(t, userID, "bulk1", "a", models.MemoryStatusPendingReview)
id2 := seedMemory(t, userID, "bulk2", "b", models.MemoryStatusPendingReview)
id3 := seedMemory(t, userID, "already-active", "c", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, map[string]interface{}{
"ids": []string{id1, id2, id3},
})
if w.Code != http.StatusOK {
t.Fatalf("bulk approve: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
approved := int(resp["approved"].(float64))
if approved != 2 {
t.Fatalf("expected 2 approved (skipping already-active), got %d", approved)
}
}
func TestMemory_AdminBulkApproveInvalidBody(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("bulkbad", "bulkbad@test.com")
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, "not-json")
if w.Code != http.StatusBadRequest {
t.Fatalf("bad body: want 400, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Field Shape Verification
// ═══════════════════════════════════════════════
func TestMemory_FieldShape(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memshape", "memshape@test.com")
seedMemory(t, userID, "test-key", "test-value", models.MemoryStatusActive)
w := h.request("GET", "/api/v1/memories", token, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
mem := data[0].(map[string]interface{})
// Verify all expected fields exist
requiredFields := []string{"id", "scope", "owner_id", "key", "value", "confidence", "status", "created_at", "updated_at"}
for _, f := range requiredFields {
if _, ok := mem[f]; !ok {
t.Errorf("missing field %q in memory object", f)
}
}
// Verify no legacy "content" field
if _, ok := mem["content"]; ok {
t.Error("memory should not have 'content' field (legacy ICD)")
}
// Verify no legacy "persona_id" field
if _, ok := mem["persona_id"]; ok {
t.Error("memory should not have 'persona_id' field (legacy ICD)")
}
// Verify scope is correct
if mem["scope"].(string) != models.MemoryScopeUser {
t.Errorf("scope should be %q, got %q", models.MemoryScopeUser, mem["scope"])
}
// Confidence should be a number
confidence, ok := mem["confidence"].(float64)
if !ok || confidence < 0 || confidence > 1 {
t.Errorf("confidence should be 0-1 float, got %v", mem["confidence"])
}
}

View File

@@ -1,802 +0,0 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/crypto"
"switchboard-core/database"
"switchboard-core/events"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/storage"
"switchboard-core/store"
"switchboard-core/tools"
"switchboard-core/treepath"
)
// ── Request / Response types ────────────────
type createMessageRequest struct {
Role string `json:"role" binding:"required,oneof=user assistant system"`
Content string `json:"content" binding:"required"`
Model string `json:"model,omitempty"`
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"`
ParentID *string `json:"parent_id,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
ParticipantType *string `json:"participant_type,omitempty"`
ParticipantID *string `json:"participant_id,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
SenderAvatar *string `json:"sender_avatar,omitempty"`
CreatedAt string `json:"created_at"`
}
type editRequest struct {
Content string `json:"content" binding:"required"`
}
type regenerateRequest struct {
Model string `json:"model,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
}
type cursorRequest struct {
ActiveLeafID string `json:"active_leaf_id" binding:"required"`
}
// MessageHandler holds dependencies for message endpoints.
type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
objStore storage.ObjectStore
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
}
// ── List Messages (flat, all branches) ──────
// GET /channels/:id/messages
func (h *MessageHandler) ListMessages(c *gin.Context) {
page, perPage, offset := parsePagination(c)
userID := getUserID(c)
channelID := c.Param("id")
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return
}
messages := make([]messageResponse, 0, len(msgs))
for _, m := range msgs {
messages = append(messages, messageResponse{
ID: m.ID,
ChannelID: m.ChannelID,
Role: m.Role,
Content: m.Content,
Model: m.Model,
TokensUsed: m.TokensUsed,
ParentID: m.ParentID,
SiblingIndex: m.SiblingIndex,
ParticipantType: m.ParticipantType,
ParticipantID: m.ParticipantID,
SenderName: m.SenderName,
SenderAvatar: m.SenderAvatar,
CreatedAt: m.CreatedAt,
})
}
// Enrich with sibling counts
for i := range messages {
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
}
c.JSON(http.StatusOK, paginatedResponse{
Data: messages,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
}
// ── Active Path ─────────────────────────────
// GET /channels/:id/path
func (h *MessageHandler) GetActivePath(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
path, err := getActivePath(channelID, userID)
if err != nil {
log.Printf("GetActivePath error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
return
}
c.JSON(http.StatusOK, gin.H{"data": path})
}
// ── Create Message (manual) ─────────────────
// POST /channels/:id/messages
func (h *MessageHandler) CreateMessage(c *gin.Context) {
var req createMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
channelID := c.Param("id")
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Use cursor for parent, compute sibling_index
effectiveUserID := userID
if isSessionAuth(c) {
effectiveUserID = c.GetString("session_id")
}
parentID, _ := getActiveLeaf(channelID, effectiveUserID)
siblingIdx := nextSiblingIndex(channelID, parentID)
participantType := "user"
participantID := userID
if isSessionAuth(c) {
participantType = "session"
participantID = c.GetString("session_id")
}
if req.Role == "assistant" {
participantType = "model"
if req.Model != "" {
participantID = req.Model
}
}
msg := &models.Message{
ChannelID: channelID,
Role: req.Role,
Content: req.Content,
Model: req.Model,
ParentID: parentID,
SiblingIndex: siblingIdx,
ParticipantType: participantType,
ParticipantID: participantID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
// Broadcast via WS to channel participants
if h.hub != nil && msg.Role == "user" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID)
}
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
SiblingIndex: msg.SiblingIndex,
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if msg.Model != "" {
resp.Model = &msg.Model
}
resp.ParentID = msg.ParentID
resp.SiblingCount = getSiblingCount(channelID, msg.ParentID)
c.JSON(http.StatusCreated, resp)
}
// ── Edit Message (create sibling) ───────────
// POST /channels/:id/messages/:msgId/edit
func (h *MessageHandler) EditMessage(c *gin.Context) {
var req editRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
// Load target — must exist, belong to channel, be a user message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
return
}
if targetRole != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"})
return
}
// Create sibling: same parent_id as the target
siblingIdx := nextSiblingIndex(channelID, targetParentID)
msg := &models.Message{
ChannelID: channelID,
Role: "user",
Content: req.Content,
ParentID: targetParentID,
SiblingIndex: siblingIdx,
ParticipantType: "user",
ParticipantID: userID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
return
}
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
ParentID: msg.ParentID,
SiblingIndex: msg.SiblingIndex,
SiblingCount: getSiblingCount(channelID, msg.ParentID),
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusCreated, resp)
}
// ── Regenerate / Complete ──────────────────────
// POST /channels/:id/messages/:msgId/regenerate
func (h *MessageHandler) Regenerate(c *gin.Context) {
var req regenerateRequest
_ = c.ShouldBindJSON(&req) // body is optional
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
// Load target message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
return
}
if targetRole != "assistant" && targetRole != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"})
return
}
// Determine context path and new message's parent based on target role
var contextPath []PathMessage
var newParentID *string
if targetRole == "assistant" {
if targetParentID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"})
return
}
contextPath, err = getPathToLeaf(channelID, *targetParentID)
newParentID = targetParentID
} else {
// user message — respond to it
contextPath, err = getPathToLeaf(channelID, messageID)
newParentID = &messageID
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"})
return
}
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
var personaSystemPrompt string
var personaID string
model := req.Model
providerConfigID := req.ProviderConfigID
temperature := req.Temperature
maxTokens := req.MaxTokens
if req.PersonaID != "" {
persona := ResolvePersona(h.stores, req.PersonaID, userID)
if persona == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"})
return
}
personaID = persona.ID
if model == "" {
model = persona.BaseModelID
}
if providerConfigID == "" && persona.ProviderConfigID != nil {
providerConfigID = *persona.ProviderConfigID
}
if temperature == nil && persona.Temperature != nil {
temperature = persona.Temperature
}
if maxTokens == 0 && persona.MaxTokens != nil {
maxTokens = *persona.MaxTokens
}
if persona.SystemPrompt != "" {
personaSystemPrompt = persona.SystemPrompt
}
}
// Fallback: channel's stored model
if model == "" {
channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID)
if channelModel != nil {
model = *channelModel
}
}
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
Model: model,
ProviderConfigID: providerConfigID,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
provider, err := providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Build LLM message array
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
if personaSystemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
} else {
systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID)
if systemPrompt != nil && *systemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
}
}
for _, m := range contextPath {
if m.Role == "system" {
continue
}
llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content})
}
provReq := providers.CompletionRequest{
Model: model,
Messages: llmMessages,
}
caps := comp.getModelCapabilities(c, model, configID)
if maxTokens > 0 {
provReq.MaxTokens = maxTokens
} else {
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if temperature != nil {
provReq.Temperature = temperature
}
// Attach tool definitions (same as normal completion)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID)
// Query channel metadata for tool context
chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID)
msgTeamID := ""
if chTeamID != nil {
msgTeamID = *chTeamID
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
TeamID: msgTeamID,
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Stream the response ──
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health, comp.runner, comp.buildExtToolMap(c.Request.Context(), userID))
// Persist as sibling (regen) with tool activity
if result.Content != "" {
siblingIdx := nextSiblingIndex(channelID, newParentID)
var toolCalls models.JSONMap
if len(result.ToolActivity) > 0 {
b, _ := json.Marshal(result.ToolActivity)
_ = json.Unmarshal(b, &toolCalls)
}
if toolCalls == nil {
toolCalls = models.JSONMap{}
}
msg := &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: model,
ToolCalls: toolCalls,
Metadata: models.JSONMap{},
ParentID: newParentID,
SiblingIndex: siblingIdx,
ParticipantType: "model",
ParticipantID: model,
}
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
} else {
flusher, _ := c.Writer.(http.Flusher)
msgJSON, _ := json.Marshal(gin.H{
"id": msg.ID,
"parent_id": newParentID,
"sibling_index": siblingIdx,
"sibling_count": getSiblingCount(channelID, newParentID),
})
fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON)
if flusher != nil {
flusher.Flush()
}
}
}
// Log usage for regeneration
comp.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
}
// ── Switch Branch (update cursor) ───────────
// PUT /channels/:id/cursor
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
var req cursorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
channelID := c.Param("id")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
// Verify the target message belongs to this channel and is not deleted.
// GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL
_, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
return
}
// Walk down to the leaf from the target
leafID, err := findLeafFromMessage(req.ActiveLeafID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"})
return
}
if err := updateCursor(channelID, userID, leafID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"})
return
}
// Return the new active path
path, err := getPathToLeaf(channelID, leafID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
return
}
c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID})
}
// ── List Siblings ───────────────────────────
// GET /channels/:id/messages/:msgId/siblings
func (h *MessageHandler) ListSiblings(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
siblings, currentIdx, err := getSiblings(messageID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"siblings": siblings,
"current_index": currentIdx,
"total": len(siblings),
})
}
// ── Ownership / Access Check ─────────────────
// userCanAccessChannel verifies channel ownership or participation,
// writing an error response if denied. Returns true if access is allowed.
func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool {
ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true
}
// userOwnsChannel is the legacy wrapper used by other handlers (completion.go).
// Delegates to userCanAccessChannel using the treepath global stores.
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true
}
// broadcastUserMessage publishes a message.created event to all user
// participants in the channel via WebSocket.
func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) {
// Look up sender display name
var displayName, username string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1
`), senderID).Scan(&displayName, &username)
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": content,
"user_id": senderID,
"display_name": displayName,
"username": username,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants in the channel
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
// Fallback: at least send to the sender
hub.PublishToUser(senderID, evt)
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil {
hub.PublishToUser(pid, evt)
}
}
}
// broadcastAssistantMessage publishes a message.created event for an assistant
// message to all user participants in the channel (except the requesting user,
// who already received the response via SSE).
func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) {
// Look up persona display name if available
var displayName, avatar string
if personaID != "" {
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1
`), personaID).Scan(&displayName, &avatar)
}
if displayName == "" {
displayName = model
}
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": content,
"model": model,
"participant_type": "persona",
"participant_id": personaID,
"display_name": displayName,
"avatar": avatar,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants except the sender (who got SSE)
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderUserID {
hub.PublishToUser(pid, evt)
}
}
}
// ── Delete Message ──────────────────────────────────────────
// DeleteMessage soft-deletes a message.
// DELETE /channels/:id/messages/:msgId
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
msgID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify message belongs to this channel and is not already deleted
var exists bool
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL)
`), msgID, channelID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"})
return
}
// Broadcast to channel participants (exclude sender — they already removed it)
broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// broadcastMessageDeleted publishes a message.deleted event to all user
// participants in the channel except the sender.
func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) {
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
})
evt := events.Event{
Label: "message.deleted",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderID {
hub.PublishToUser(pid, evt)
}
}
}

View File

@@ -1,91 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ModelPrefsHandler handles user model preference endpoints.
type ModelPrefsHandler struct {
stores store.Stores
}
func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler {
return &ModelPrefsHandler{stores: s}
}
// GetPreferences returns the user's model preferences.
func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) {
userID := getUserID(c)
prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"})
return
}
if prefs == nil {
prefs = []models.UserModelSetting{}
}
c.JSON(http.StatusOK, gin.H{"data": prefs})
}
// SetPreference upserts a single model preference.
func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID string `json:"provider_config_id" binding:"required"`
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pcid := req.ProviderConfigID
patch := models.UserModelSettingPatch{
ProviderConfigID: &pcid,
Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder,
}
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, &pcid, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
}
// BulkSetPreferences sets hidden state for multiple model+provider pairs at once.
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
userID := getUserID(c)
var req struct {
Entries []models.HiddenEntry `json:"entries" binding:"required"`
Hidden bool `json:"hidden"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)})
}

View File

@@ -1,363 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Model Prefs Test Harness ──────────────
type modelPrefsHarness struct {
*testHarness
userToken string
userID string
user2Token string
user2ID string
configID string // seeded provider_config for preference tests
}
func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
modelPrefs := NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// Seed two users
userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "prefuser@test.com", "user")
user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
user2Token := makeToken(user2ID, "prefuser2@test.com", "user")
// Seed a provider config for preference targets
configID := uuid.New().String()
if database.IsSQLite() {
database.TestDB.Exec(`
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
configID)
} else {
database.TestDB.Exec(`
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
configID)
}
return &modelPrefsHarness{
testHarness: &testHarness{router: r, t: t},
userToken: userToken,
userID: userID,
user2Token: user2Token,
user2ID: user2ID,
configID: configID,
}
}
// ── GET /models/preferences — empty state ─
func TestModelPrefs_Get_Empty(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
data, ok := body["data"]
if !ok {
t.Fatal("response must have 'data' key")
}
arr, ok := data.([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(arr) != 0 {
t.Fatalf("expected empty array, got %d items", len(arr))
}
}
// ── PUT + GET round-trip ──────────────────
func TestModelPrefs_Set_RoundTrip(t *testing.T) {
h := setupModelPrefsHarness(t)
// Set preference
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"provider_config_id": h.configID,
"hidden": true,
"sort_order": 5,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String())
}
// Read back
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("GET: got %d", resp.Code)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 1 {
t.Fatalf("expected 1 preference, got %d", len(arr))
}
pref := arr[0].(map[string]interface{})
if pref["model_id"] != "test-model-1" {
t.Errorf("model_id: got %v", pref["model_id"])
}
if pref["provider_config_id"] != h.configID {
t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID)
}
if pref["hidden"] != true {
t.Errorf("hidden: got %v, want true", pref["hidden"])
}
if pref["sort_order"] != float64(5) {
t.Errorf("sort_order: got %v, want 5", pref["sort_order"])
}
}
// ── Upsert — no duplicate rows ────────────
func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) {
h := setupModelPrefsHarness(t)
for _, hidden := range []bool{true, false, true} {
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"provider_config_id": h.configID,
"hidden": hidden,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code)
}
}
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 1 {
t.Fatalf("upsert produced %d rows, want 1", len(arr))
}
pref := arr[0].(map[string]interface{})
if pref["hidden"] != true {
t.Errorf("final hidden state should be true, got %v", pref["hidden"])
}
}
// ── Validation: missing fields ────────────
func TestModelPrefs_Set_MissingModelID(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"provider_config_id": h.configID,
"hidden": true,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing model_id: want 400, got %d", resp.Code)
}
}
func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"hidden": true,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code)
}
}
// ── Bulk set hidden ───────────────────────
func TestModelPrefs_BulkSetHidden(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{
"entries": []map[string]string{
{"model_id": "bulk-model-a", "provider_config_id": h.configID},
{"model_id": "bulk-model-b", "provider_config_id": h.configID},
},
"hidden": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String())
}
var bulkResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&bulkResp)
if bulkResp["count"] != float64(2) {
t.Errorf("bulk count: got %v, want 2", bulkResp["count"])
}
// Verify both are hidden
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
hiddenCount := 0
for _, raw := range arr {
p := raw.(map[string]interface{})
if p["hidden"] == true {
hiddenCount++
}
}
if hiddenCount < 2 {
t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount)
}
}
// ── User isolation ────────────────────────
func TestModelPrefs_UserIsolation(t *testing.T) {
h := setupModelPrefsHarness(t)
// User 1 sets a preference
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "isolated-model",
"provider_config_id": h.configID,
"hidden": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("user1 PUT: got %d", resp.Code)
}
// User 2 should see empty preferences
resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("user2 GET: got %d", resp.Code)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("user2 should have 0 prefs, got %d", len(arr))
}
}
// ── Shape validation ──────────────────────
func TestModelPrefs_ResponseShape(t *testing.T) {
h := setupModelPrefsHarness(t)
// Create a preference so we have something to inspect
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "shape-model",
"provider_config_id": h.configID,
"hidden": false,
"sort_order": 0,
})
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) == 0 {
t.Fatal("expected at least 1 preference for shape check")
}
pref := arr[0].(map[string]interface{})
requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"}
for _, f := range requiredFields {
if _, ok := pref[f]; !ok {
t.Errorf("missing required field %q in preference response", f)
}
}
// id, user_id, model_id, provider_config_id should be non-empty strings
for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} {
v, _ := pref[f].(string)
if v == "" {
t.Errorf("field %q should be a non-empty string, got %v", f, pref[f])
}
}
}
// ── Different provider_config_id = different entry ──
func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) {
h := setupModelPrefsHarness(t)
// Seed a second provider config
config2ID := uuid.New().String()
database.TestDB.Exec(dialectSQL(
"INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"),
config2ID)
// Set preference for same model under two providers
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "dual-provider-model",
"provider_config_id": h.configID,
"hidden": true,
})
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "dual-provider-model",
"provider_config_id": config2ID,
"hidden": false,
})
// Should have two distinct entries
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
matches := 0
for _, raw := range arr {
p := raw.(map[string]interface{})
if p["model_id"] == "dual-provider-model" {
matches++
}
}
if matches != 2 {
t.Fatalf("same model, different providers: expected 2 entries, got %d", matches)
}
}

View File

@@ -1,146 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/store"
)
// providerSyncTimeout is the maximum time allowed for an outbound provider
// HTTP call during model sync. Configurable via PROVIDER_SYNC_TIMEOUT env
// var (seconds). Default 30s.
var providerSyncTimeout = func() time.Duration {
if v := os.Getenv("PROVIDER_SYNC_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 30 * time.Second
}()
// ErrUpstreamTimeout is returned when a provider API call exceeds the
// configured timeout. Handlers use this to distinguish upstream timeouts
// from other errors and return 504 instead of 502.
var ErrUpstreamTimeout = errors.New("upstream timeout")
// syncResult holds the outcome of a model sync operation.
type syncResult struct {
Added int `json:"added"`
Updated int `json:"updated"`
Total int `json:"total"`
}
// syncProviderModels fetches models from a provider's API and syncs them into the catalog.
// New models default to 'disabled' visibility (admin must explicitly enable for global providers).
// apiKeyPlain is the decrypted API key — callers are responsible for decryption.
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
prov, err := providers.Get(cfg.Provider)
if err != nil {
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
}
proxyURL := ""
if cfg.ProxyURL != nil {
proxyURL = *cfg.ProxyURL
}
provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: apiKeyPlain,
ProxyMode: cfg.ProxyMode,
ProxyURL: proxyURL,
}
if cfg.Headers != nil {
customHeaders := make(map[string]string)
for k, v := range cfg.Headers {
if s, ok := v.(string); ok {
customHeaders[k] = s
}
}
provCfg.CustomHeaders = customHeaders
}
// Parse config for any extra settings the provider needs
if cfg.Config != nil {
raw, _ := json.Marshal(cfg.Config)
var extra map[string]string
if json.Unmarshal(raw, &extra) == nil {
if provCfg.CustomHeaders == nil {
provCfg.CustomHeaders = make(map[string]string)
}
for k, v := range extra {
provCfg.CustomHeaders[k] = v
}
}
}
// Wrap context with timeout for the outbound provider call.
// All provider ListModels implementations use http.NewRequestWithContext,
// so the deadline propagates to the HTTP transport automatically.
syncCtx, cancel := context.WithTimeout(ctx, providerSyncTimeout)
defer cancel()
provModels, err := prov.ListModels(syncCtx, provCfg)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return syncResult{}, fmt.Errorf("%w: %s", ErrUpstreamTimeout, cfg.Provider)
}
return syncResult{}, err
}
syncEntries := make([]store.CatalogSyncEntry, len(provModels))
for i, m := range provModels {
syncEntries[i] = store.CatalogSyncEntry{
ModelID: m.ID,
DisplayName: m.Name,
ModelType: m.Type,
Capabilities: m.Capabilities,
Pricing: m.Pricing,
}
}
added, updated, err := stores.Catalog.UpsertFromSync(ctx, cfg.ID, syncEntries)
if err != nil {
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
}
// Sync pricing from provider catalog (won't overwrite manual admin overrides)
if stores.Pricing != nil {
for _, m := range provModels {
if m.Pricing != nil {
if err := stores.Pricing.UpsertFromCatalog(ctx, cfg.ID, m.ID, m.Pricing); err != nil {
log.Printf("warn: pricing sync for %s/%s failed: %v", cfg.ID, m.ID, err)
}
}
}
}
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
}
// syncAndEnableProviderModels fetches models and auto-enables them all.
// Used for personal (BYOK) providers — the user explicitly added this provider to use it.
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
result, err := syncProviderModels(ctx, stores, cfg, apiKeyPlain)
if err != nil {
return result, err
}
// Auto-enable: user added this key to USE it, not to stare at disabled models
if result.Total > 0 {
if err := stores.Catalog.BulkSetVisibility(ctx, cfg.ID, "enabled"); err != nil {
log.Printf("warn: auto-enable models for provider %s failed: %v", cfg.ID, err)
}
}
return result, nil
}

View File

@@ -1,602 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/notelinks"
"switchboard-core/store"
)
// ── Request / Response Types ────────────────
type createNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
SourceMessageID string `json:"source_message_id"`
}
type updateNoteRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
FolderPath *string `json:"folder_path"`
Tags []string `json:"tags"`
// Mode controls how content is applied: "replace" (default), "append", "prepend"
Mode string `json:"mode"`
}
type noteResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID *string `json:"source_channel_id,omitempty"`
SourceMessageID *string `json:"source_message_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type noteListItem struct {
ID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
Preview string `json:"preview"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type searchResult struct {
noteListItem
Rank float64 `json:"rank"`
Headline string `json:"headline"`
}
// NoteHandler handles notes CRUD.
type NoteHandler struct {
stores store.Stores
}
// NewNoteHandler creates a new handler.
func NewNoteHandler(s ...store.Stores) *NoteHandler {
h := &NoteHandler{}
if len(s) > 0 {
h.stores = s[0]
}
return h
}
// toNoteResponse converts a models.Note to a noteResponse.
func toNoteResponse(n *models.Note) noteResponse {
tags := n.Tags
if tags == nil {
tags = []string{}
}
return noteResponse{
ID: n.ID,
Title: n.Title,
Content: n.Content,
FolderPath: n.FolderPath,
Tags: tags,
SourceChannelID: n.SourceChannelID,
SourceMessageID: n.SourceMessageID,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func toNoteListItem(n models.Note) noteListItem {
tags := n.Tags
if tags == nil {
tags = []string{}
}
preview := n.Content
if len(preview) > 200 {
preview = preview[:200]
}
return noteListItem{
ID: n.ID,
Title: n.Title,
FolderPath: n.FolderPath,
Tags: tags,
Preview: preview,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ── Create ──────────────────────────────────
// POST /api/v1/notes
func (h *NoteHandler) Create(c *gin.Context) {
var req createNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
folder := normalizeFolderPath(req.FolderPath)
tags := req.Tags
if tags == nil {
tags = []string{}
}
var sourceChannelID *string
if req.SourceChannelID != "" {
sourceChannelID = &req.SourceChannelID
}
var sourceMessageID *string
if req.SourceMessageID != "" {
sourceMessageID = &req.SourceMessageID
}
note := &models.Note{
UserID: userID,
Title: req.Title,
Content: req.Content,
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
SourceMessageID: sourceMessageID,
}
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
return
}
// Extract and store wikilinks
h.extractAndStoreLinks(c, note.ID, note.Content, userID)
// Resolve dangling links from other notes that reference this title
if h.stores.NoteLinks != nil {
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
}
// Auto-associate note with source channel's project (v0.19.0)
if req.SourceChannelID != "" && h.stores.Projects != nil {
projID, _ := h.stores.Projects.GetProjectIDForChannel(
c.Request.Context(), req.SourceChannelID)
if projID != "" {
_ = h.stores.Projects.AddNote(c.Request.Context(), projID, note.ID)
}
}
c.JSON(http.StatusCreated, toNoteResponse(note))
}
// ── Get ─────────────────────────────────────
// GET /api/v1/notes/:id
func (h *NoteHandler) Get(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Verify ownership
if note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, toNoteResponse(note))
}
// ── Update ──────────────────────────────────
// PUT /api/v1/notes/:id
func (h *NoteHandler) Update(c *gin.Context) {
var req updateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Build fields map
fields := map[string]interface{}{}
if req.Title != nil {
fields["title"] = *req.Title
}
if req.Content != nil {
mode := strings.ToLower(req.Mode)
switch mode {
case "append":
fields["content"] = existing.Content + *req.Content
case "prepend":
fields["content"] = *req.Content + existing.Content
default: // "replace" or empty
fields["content"] = *req.Content
}
}
if req.FolderPath != nil {
fields["folder_path"] = normalizeFolderPath(*req.FolderPath)
}
if req.Tags != nil {
fields["tags"] = req.Tags
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Notes.Update(c.Request.Context(), noteID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
return
}
// Re-fetch to get updated timestamps
updated, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated note"})
return
}
// Re-extract links if content changed
if req.Content != nil {
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
}
c.JSON(http.StatusOK, toNoteResponse(updated))
}
// ── Delete ──────────────────────────────────
// DELETE /api/v1/notes/:id
func (h *NoteHandler) Delete(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ── Bulk Delete ────────────────────────────
// POST /api/v1/notes/bulk-delete
func (h *NoteHandler) BulkDelete(c *gin.Context) {
var req struct {
IDs []string `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"})
return
}
if len(req.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"})
return
}
userID := getUserID(c)
count, err := h.stores.Notes.BulkDelete(c.Request.Context(), req.IDs, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": count})
}
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
func (h *NoteHandler) List(c *gin.Context) {
userID := getUserID(c)
folder := c.Query("folder")
tag := c.Query("tag")
sort := c.DefaultQuery("sort", "updated_desc")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 200 {
limit = 50
}
opts := store.NoteListOptions{
ListOptions: store.ListOptions{
Limit: limit,
Offset: offset,
},
FolderPath: normalizeFolderPath(folder),
Tag: tag,
}
if folder == "" {
opts.FolderPath = ""
}
// Map sort parameter
switch sort {
case "created_asc":
opts.Sort = "created_at"
opts.Order = "ASC"
case "created_desc":
opts.Sort = "created_at"
opts.Order = "DESC"
case "updated_asc":
opts.Sort = "updated_at"
opts.Order = "ASC"
case "title_asc":
opts.Sort = "title"
opts.Order = "ASC"
case "title_desc":
opts.Sort = "title"
opts.Order = "DESC"
default: // "updated_desc"
opts.Sort = ""
opts.Order = ""
}
notes, total, err := h.stores.Notes.ListForUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
items := make([]noteListItem, 0, len(notes))
for _, n := range notes {
items = append(items, toNoteListItem(n))
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// ── Search ──────────────────────────────────
// GET /api/v1/notes/search?q=query&limit=20
func (h *NoteHandler) Search(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 || limit > 100 {
limit = 20
}
// SearchKeyword handles dialect internally: PG uses ts_rank/ts_headline, SQLite uses LIKE.
storeResults, err := h.stores.Notes.SearchKeyword(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
results := make([]searchResult, 0, len(storeResults))
for _, sr := range storeResults {
tags := sr.Tags
if tags == nil {
tags = []string{}
}
results = append(results, searchResult{
noteListItem: noteListItem{
ID: sr.ID,
Title: sr.Title,
FolderPath: sr.FolderPath,
Tags: tags,
Preview: sr.Excerpt,
},
Rank: sr.Rank,
Headline: sr.Headline,
})
}
c.JSON(http.StatusOK, gin.H{
"data": results,
"query": q,
"total": len(results),
})
}
// ── List Folders ────────────────────────────
// GET /api/v1/notes/folders
func (h *NoteHandler) ListFolders(c *gin.Context) {
userID := getUserID(c)
storeFolders, err := h.stores.Notes.ListFolders(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
type folderInfo struct {
Path string `json:"path"`
Count int `json:"count"`
}
folders := make([]folderInfo, 0, len(storeFolders))
for _, f := range storeFolders {
folders = append(folders, folderInfo{Path: f.Path, Count: f.Count})
}
c.JSON(http.StatusOK, gin.H{"data": folders})
}
// ── Helpers ─────────────────────────────────
// normalizeFolderPath ensures consistent folder path format.
func normalizeFolderPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
// Collapse double slashes
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
return p
}
// ── Wikilink Endpoints ──────────────────────
// GET /api/v1/notes/search-titles?q=query&limit=10
func (h *NoteHandler) SearchTitles(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
notes, err := h.stores.Notes.SearchTitles(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
type titleResult struct {
ID string `json:"id"`
Title string `json:"title"`
}
results := make([]titleResult, 0, len(notes))
for _, n := range notes {
results = append(results, titleResult{ID: n.ID, Title: n.Title})
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/:id/backlinks
func (h *NoteHandler) Backlinks(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
results, err := h.stores.NoteLinks.Backlinks(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load backlinks"})
return
}
if results == nil {
results = []models.NoteLinkResult{}
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/graph
func (h *NoteHandler) Graph(c *gin.Context) {
userID := getUserID(c)
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, models.NoteGraph{
Nodes: []models.NoteGraphNode{},
Edges: []models.NoteGraphEdge{},
Unresolved: []models.NoteGraphDangling{},
})
return
}
graph, err := h.stores.NoteLinks.Graph(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load graph"})
return
}
c.JSON(http.StatusOK, graph)
}
// ── Link Extraction Helper ──────────────────
// extractAndStoreLinks parses [[wikilinks]] from content and stores them.
func (h *NoteHandler) extractAndStoreLinks(c *gin.Context, noteID, content, userID string) {
if h.stores.NoteLinks == nil {
return
}
links := notelinks.ExtractWikilinks(content)
if len(links) == 0 {
// Clear any existing links
h.stores.NoteLinks.ReplaceLinks(c.Request.Context(), noteID, nil)
return
}
// Resolve titles to note IDs
ctx := c.Request.Context()
for i, link := range links {
notes, err := h.stores.Notes.SearchTitles(ctx, userID, link.TargetTitle, 1)
if err == nil {
for _, n := range notes {
if strings.EqualFold(n.Title, link.TargetTitle) {
id := n.ID
links[i].TargetNoteID = &id
break
}
}
}
}
h.stores.NoteLinks.ReplaceLinks(ctx, noteID, links)
}

View File

@@ -1,127 +0,0 @@
package handlers
// package_export.go — v0.30.0 CS3
//
// Exports an installed package as a downloadable .pkg archive for
// cross-instance sharing. Includes manifest, static assets, and
// Starlark scripts. Does NOT include extension table data or secrets.
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/store"
)
// PackageExportHandler handles package export operations.
type PackageExportHandler struct {
stores store.Stores
packagesDir string
}
// NewPackageExportHandler creates a new export handler.
func NewPackageExportHandler(s store.Stores, packagesDir string) *PackageExportHandler {
return &PackageExportHandler{stores: s, packagesDir: packagesDir}
}
// ExportPackage downloads an installed package as a .pkg archive.
// GET /api/v1/admin/packages/:id/export
func (h *PackageExportHandler) ExportPackage(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
if pkg.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot export core packages"})
return
}
// Build manifest for export
manifest := make(map[string]any, len(pkg.Manifest))
for k, v := range pkg.Manifest {
// Skip internal fields that shouldn't be exported
if k == "_script" {
continue
}
manifest[k] = v
}
// Include package_settings as default_settings so importing instance
// gets admin defaults (but not secrets)
if len(pkg.PackageSettings) > 0 && string(pkg.PackageSettings) != "{}" {
var ps map[string]any
if json.Unmarshal(pkg.PackageSettings, &ps) == nil && len(ps) > 0 {
manifest["default_settings"] = ps
}
}
// Set response headers
filename := fmt.Sprintf("%s-%s.pkg", id, pkg.Version)
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Create zip writer directly to response
zw := zip.NewWriter(c.Writer)
defer zw.Close()
// Write manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
return
}
mf, err := zw.Create("manifest.json")
if err != nil {
return
}
mf.Write(manifestJSON)
// Include Starlark script as script.star if present
if script, ok := pkg.Manifest["_starlark_script"].(string); ok && script != "" {
sf, err := zw.Create("script.star")
if err == nil {
sf.Write([]byte(script))
}
}
// Walk packagesDir/{id}/ for static assets
if h.packagesDir != "" {
assetsDir := filepath.Join(h.packagesDir, id)
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, err := filepath.Rel(assetsDir, path)
if err != nil {
return nil
}
// Normalize path separators for zip
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
f, err := zw.Create(relPath)
if err != nil {
return nil
}
src, err := os.Open(path)
if err != nil {
return nil
}
defer src.Close()
io.Copy(f, src)
return nil
})
}
}
}

View File

@@ -1,160 +0,0 @@
package handlers
// package_migrations.go — v0.30.0 CS1
//
// Schema migration engine for extension packages. When a package declares
// schema_version and migrations in its manifest, this engine runs Starlark
// migration scripts on install (fresh or upgrade).
//
// Manifest format:
// {
// "schema_version": 2,
// "migrations": {
// "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})",
// "2": "def migrate(db):\n ..."
// }
// }
//
// On fresh install: runs migrations 1..schema_version.
// On upgrade: runs migrations (current+1)..new_schema_version.
// On downgrade: rejected (409).
import (
"context"
"database/sql"
"fmt"
"log"
"strconv"
"go.starlark.net/starlark"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// ParseSchemaVersion extracts the "schema_version" integer from a manifest.
// Returns 0 if not present or not a number.
func ParseSchemaVersion(manifest map[string]any) int {
raw, ok := manifest["schema_version"]
if !ok {
return 0
}
switch v := raw.(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// ParseMigrations extracts the "migrations" map from a manifest.
// Keys are string integers ("1", "2", ...), values are Starlark source code.
func ParseMigrations(manifest map[string]any) map[int]string {
raw, ok := manifest["migrations"]
if !ok {
return nil
}
migrationsRaw, ok := raw.(map[string]any)
if !ok || len(migrationsRaw) == 0 {
return nil
}
result := make(map[int]string, len(migrationsRaw))
for key, val := range migrationsRaw {
version, err := strconv.Atoi(key)
if err != nil {
log.Printf("[pkg-migrate] skipping non-integer migration key %q", key)
continue
}
script, ok := val.(string)
if !ok || script == "" {
log.Printf("[pkg-migrate] skipping empty migration for version %d", version)
continue
}
result[version] = script
}
return result
}
// RunSchemaMigrations executes Starlark migration scripts from fromVersion+1
// to toVersion, updating schema_version in the store after each successful step.
//
// The sandbox runs each migration script with a db module at write level,
// scoped to the package's namespace. This bypasses the permission system
// because migrations are admin-initiated.
//
// On error, returns immediately — partial migration state is tracked via
// the per-step schema_version update.
func RunSchemaMigrations(
ctx context.Context,
sb *sandbox.Sandbox,
stores store.Stores,
db *sql.DB,
isPostgres bool,
packageID string,
manifest map[string]any,
fromVersion int,
toVersion int,
) error {
if fromVersion >= toVersion {
return nil
}
migrations := ParseMigrations(manifest)
if migrations == nil && toVersion > 0 {
// No migration scripts but schema_version declared — just set the version
return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion)
}
for step := fromVersion + 1; step <= toVersion; step++ {
script, ok := migrations[step]
if !ok {
// No script for this step — just bump the version
log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step)
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
return fmt.Errorf("set schema_version to %d: %w", step, err)
}
continue
}
log.Printf("[pkg-migrate] %s: running migration %d", packageID, step)
// Build a db module at write level for the migration
dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{
PackageID: packageID,
CanWrite: true,
DB: db,
IsPostgres: isPostgres,
})
modules := map[string]starlark.Value{
"db": dbModule,
}
// Execute the migration script
result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules)
if err != nil {
return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err)
}
// Call migrate(db) if defined
if migrateFn, ok := result.Globals["migrate"]; ok {
if callable, ok := migrateFn.(starlark.Callable); ok {
_, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil)
if err != nil {
return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err)
}
}
}
// Mark this step as complete
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
return fmt.Errorf("set schema_version to %d: %w", step, err)
}
log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step)
}
return nil
}

View File

@@ -1,360 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Channel Participants Handler ──────────────
// ICD §3.7: Manages polymorphic participants per channel.
// Routes:
// GET /channels/:id/participants — list
// POST /channels/:id/participants — add
// PATCH /channels/:id/participants/:participantId — update role
// DELETE /channels/:id/participants/:participantId — remove
// GET /channels/:id/presence — who's online
type ParticipantHandler struct {
stores store.Stores
}
func NewParticipantHandler(stores store.Stores) *ParticipantHandler {
return &ParticipantHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ParticipantHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Caller must be a participant (any role)
if !h.requireParticipant(c, channelID, userID) {
return
}
participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"})
return
}
if participants == nil {
participants = []models.ChannelParticipant{}
}
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Add ──────────────────────────────────────
type addParticipantRequest struct {
ParticipantType string `json:"participant_type" binding:"required"`
ParticipantID string `json:"participant_id" binding:"required"`
Role string `json:"role"`
}
func (h *ParticipantHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Only owners can add participants
if !h.requireOwner(c, channelID, userID) {
return
}
var req addParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate participant_type
switch req.ParticipantType {
case "user", "persona", "session":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"})
return
}
// Default role
role := req.Role
if role == "" {
role = "member"
}
switch role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Build participant
p := &models.ChannelParticipant{
ChannelID: channelID,
ParticipantType: req.ParticipantType,
ParticipantID: req.ParticipantID,
Role: role,
}
// If adding a persona, resolve display_name and avatar from persona record,
// and auto-add persona's model to channel_models roster.
if req.ParticipantType == "persona" {
persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"})
return
}
dn := persona.Name
p.DisplayName = &dn
if persona.Avatar != "" {
p.AvatarURL = &persona.Avatar
}
// Auto-add persona's model to channel_models roster
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: persona.BaseModelID,
ProviderConfigID: derefStr(persona.ProviderConfigID),
PersonaID: &req.ParticipantID,
Handle: persona.Handle,
DisplayName: persona.Name,
SystemPrompt: persona.SystemPrompt,
IsDefault: false,
}
_ = h.stores.Channels.SetModel(c.Request.Context(), cm)
} else if req.ParticipantType == "user" {
// Resolve user display name
user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"})
return
}
dn := user.DisplayName
if dn == "" {
dn = user.Username
}
p.DisplayName = &dn
if user.AvatarURL != "" {
p.AvatarURL = &user.AvatarURL
}
}
if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil {
// Check for duplicate
if isDuplicateErr(err) {
c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
return
}
// Return updated participant list
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"data": participants})
}
// ── Update Role ─────────────────────────────
type updateParticipantRequest struct {
Role string `json:"role" binding:"required"`
}
func (h *ParticipantHandler) Update(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
var req updateParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch req.Role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Remove ──────────────────────────────────
func (h *ParticipantHandler) Remove(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
// Cannot remove the last owner
if p.Role == "owner" {
owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner")
if owners <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"})
return
}
}
// v0.23.2: DM guard — cannot drop below 2 human participants
channelType, _, _ := h.stores.Channels.GetTypeAndTeamID(c.Request.Context(), channelID)
if channelType == "dm" && p.ParticipantType == "user" {
userCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "user")
if userCount <= 2 {
c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"})
return
}
}
// v0.23.2: Group guard — cannot remove the last persona
if channelType == "group" && p.ParticipantType == "persona" {
personaCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "persona")
if personaCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"})
return
}
}
// If removing a persona, also remove its auto-created model roster entry
if p.ParticipantType == "persona" {
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
}
if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Helpers ──────────────────────────────────
// requireParticipant checks the user is any participant in the channel.
func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool {
ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"})
return false
}
if !ok {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
return true
}
// requireOwner checks the user is an owner participant in the channel.
func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool {
role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID)
if err != nil {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
if role != "owner" {
c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"})
return false
}
return true
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// isDuplicateErr detects unique constraint violations across both Postgres and SQLite.
func isDuplicateErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// Postgres: "duplicate key value violates unique constraint"
// SQLite: "UNIQUE constraint failed"
return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,202 +0,0 @@
package handlers
// persona_groups.go — Persona group (roster template) CRUD (v0.23.2)
//
// v0.29.0: Raw SQL replaced with PersonaGroupStore methods.
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
type PersonaGroupHandler struct {
stores store.Stores
}
func NewPersonaGroupHandler(s store.Stores) *PersonaGroupHandler {
return &PersonaGroupHandler{stores: s}
}
// ── List ────────────────────────────────────────
func (h *PersonaGroupHandler) List(c *gin.Context) {
userID := getUserID(c)
groups, err := h.stores.PersonaGroups.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
return
}
for i := range groups {
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), groups[i].ID)
groups[i].Members = members
}
c.JSON(http.StatusOK, gin.H{"data": groups})
}
// ── Get ─────────────────────────────────────────
func (h *PersonaGroupHandler) Get(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
g, err := h.stores.PersonaGroups.Get(c.Request.Context(), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"})
return
}
if g == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), g.ID)
if members == nil {
members = []models.PersonaGroupMember{}
}
g.Members = members
c.JSON(http.StatusOK, g)
}
// ── Create ──────────────────────────────────────
func (h *PersonaGroupHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
g := &models.PersonaGroup{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
OwnerID: userID,
Scope: "personal",
}
if err := h.stores.PersonaGroups.Create(c.Request.Context(), g); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
g.Members = []models.PersonaGroupMember{}
c.JSON(http.StatusCreated, g)
}
// ── Update ──────────────────────────────────────
func (h *PersonaGroupHandler) Update(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), id)
if err != nil || ownerID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = strings.TrimSpace(*req.Name)
}
if req.Description != nil {
fields["description"] = *req.Description
}
if len(fields) > 0 {
_ = h.stores.PersonaGroups.Update(c.Request.Context(), id, fields)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Delete ──────────────────────────────────────
func (h *PersonaGroupHandler) Delete(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
n, err := h.stores.PersonaGroups.Delete(c.Request.Context(), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Add Member ──────────────────────────────────
func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
userID := getUserID(c)
groupID := c.Param("id")
var req struct {
PersonaID string `json:"persona_id" binding:"required"`
IsLeader bool `json:"is_leader"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
if err := h.stores.PersonaGroups.AddMember(c.Request.Context(), groupID, req.PersonaID, req.IsLeader); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true})
}
// ── Remove Member ───────────────────────────────
func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
userID := getUserID(c)
groupID := c.Param("id")
memberID := c.Param("memberId")
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
_ = h.stores.PersonaGroups.RemoveMember(c.Request.Context(), memberID, groupID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -1,569 +0,0 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// PersonaHandler handles persona endpoints.
type PersonaHandler struct {
stores store.Stores
}
func NewPersonaHandler(s store.Stores) *PersonaHandler {
return &PersonaHandler{stores: s}
}
// ── User Personas (personal scope) ──────────
func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
userID := getUserID(c)
personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
}
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
userID := getUserID(c)
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"})
return
}
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopePersonal
persona.OwnerID = &userID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
// Users can only edit their own personal personas
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"})
return
}
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"})
return
}
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Team Personas ───────────────────────────
func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
teamID := c.Param("teamId")
personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"})
return
}
if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
}
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
userID := getUserID(c)
teamID := c.Param("teamId")
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeTeam
persona.OwnerID = &teamID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
// UpdateTeamPersona updates a team-scoped persona. Verifies the persona
// belongs to the team specified in the URL path.
func (h *PersonaHandler) UpdateTeamPersona(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
// DeleteTeamPersona deletes a team-scoped persona. Verifies the persona
// belongs to the team specified in the URL path before deleting.
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Admin Personas (global scope) ───────────
func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
personas, err := h.stores.Personas.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
}
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
userID := getUserID(c)
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeGlobal
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) {
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Request Types ───────────────────────────
type personaRequest struct {
Name string `json:"name" binding:"required"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
BaseModelID string `json:"base_model_id,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
IsShared bool `json:"is_shared,omitempty"`
}
func (r *personaRequest) toPersona() *models.Persona {
p := &models.Persona{
Name: r.Name,
Handle: r.Handle,
Description: r.Description,
Icon: r.Icon,
BaseModelID: r.BaseModelID,
ProviderConfigID: r.ProviderConfigID,
SystemPrompt: r.SystemPrompt,
Temperature: r.Temperature,
MaxTokens: r.MaxTokens,
ThinkingBudget: r.ThinkingBudget,
TopP: r.TopP,
IsActive: true,
IsShared: r.IsShared,
}
return p
}
// ResolvePersona loads a persona by ID and returns it if the user has access.
// Returns nil if not found, inactive, or not accessible.
func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona {
ctx := context.Background()
p, err := stores.Personas.GetByID(ctx, personaID)
if err != nil || !p.IsActive {
return nil
}
ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID)
if err != nil || !ok {
return nil
}
return p
}
// ── Persona-KB Binding Endpoints (v0.17.0) ──────
// GetPersonaKBs returns the knowledge bases bound to a persona.
func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) {
personaID := c.Param("id")
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// GetTeamPersonaKBs returns KBs bound to a persona, verifying team ownership.
func (h *PersonaHandler) GetTeamPersonaKBs(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// SetPersonaKBs replaces the knowledge bases bound to a persona.
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
personaID := c.Param("id")
var req struct {
KBIDs []string `json:"kb_ids"`
AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// SetTeamPersonaKBs replaces KBs bound to a persona, verifying team ownership.
func (h *PersonaHandler) SetTeamPersonaKBs(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
var req struct {
KBIDs []string `json:"kb_ids"`
AutoSearch map[string]bool `json:"auto_search"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Persona Tool Grants (v0.25.0) ───────────
// GetPersonaToolGrants returns the tool names granted to a persona.
// Empty list = persona inherits all context-available tools.
func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
if grants == nil {
grants = []string{}
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetPersonaToolGrants replaces the tool grants for a persona.
// Sending an empty tool_names array clears all grants (persona inherits all tools).
func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert tool names to Grant objects
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── User-Scoped Persona Avatar (with ownership check) ─────
// UploadUserPersonaAvatar uploads an avatar for a personal persona,
// verifying the caller owns it.
func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) {
userID := getUserID(c)
personaID := c.Param("id")
// Verify ownership
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"})
return
}
// Delegate to the shared avatar handler
UploadPersonaAvatar(h.stores.Personas, c)
}
// DeleteUserPersonaAvatar deletes an avatar for a personal persona,
// verifying the caller owns it.
func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) {
userID := getUserID(c)
personaID := c.Param("id")
// Verify ownership
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"})
return
}
// Delegate to the shared avatar handler
DeletePersonaAvatar(h.stores.Personas, c)
}
// ── Team-Scoped Helpers ─────────────────────
// requireTeamPersona loads a persona by ID and verifies it belongs to
// the team in the URL path. Returns the persona on success or writes
// an error response and returns nil.
func (h *PersonaHandler) requireTeamPersona(c *gin.Context) *models.Persona {
teamID := c.Param("teamId")
personaID := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return nil
}
if existing.Scope != models.ScopeTeam || existing.OwnerID == nil || *existing.OwnerID != teamID {
c.JSON(http.StatusForbidden, gin.H{"error": "persona does not belong to this team"})
return nil
}
return existing
}
// ── Team-Scoped Persona Tool Grants ─────────
// GetTeamPersonaToolGrants returns tool grants for a team persona,
// verifying team ownership.
func (h *PersonaHandler) GetTeamPersonaToolGrants(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
if grants == nil {
grants = []string{}
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetTeamPersonaToolGrants replaces tool grants for a team persona,
// verifying team ownership.
func (h *PersonaHandler) SetTeamPersonaToolGrants(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Team-Scoped Persona Avatar ──────────────
// UploadTeamPersonaAvatar uploads an avatar for a team persona,
// verifying team ownership.
func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
UploadPersonaAvatar(h.stores.Personas, c)
}
// DeleteTeamPersonaAvatar deletes an avatar for a team persona,
// verifying team ownership.
func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
DeletePersonaAvatar(h.stores.Personas, c)
}

View File

@@ -1,461 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Request / Response Types ────────────────
type createProjectRequest struct {
Name string `json:"name" binding:"required,max=200"`
Description string `json:"description,omitempty"`
Color *string `json:"color,omitempty"`
Icon *string `json:"icon,omitempty"`
}
type updateProjectRequest = models.ProjectPatch
type addChannelRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
Position int `json:"position"`
}
type reorderChannelsRequest struct {
ChannelIDs []string `json:"channel_ids" binding:"required"`
}
type addKBRequest struct {
KBID string `json:"kb_id" binding:"required"`
AutoSearch bool `json:"auto_search"`
}
type addNoteRequest struct {
NoteID string `json:"note_id" binding:"required"`
}
// ProjectHandler handles project CRUD and associations.
type ProjectHandler struct {
stores store.Stores
}
// NewProjectHandler creates a new project handler.
func NewProjectHandler(s store.Stores) *ProjectHandler {
return &ProjectHandler{stores: s}
}
// ── CRUD ────────────────────────────────────
func (h *ProjectHandler) List(c *gin.Context) {
userID := getUserID(c)
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
includeArchived := c.Query("include_archived") == "true"
projects, err := h.stores.Projects.ListForUser(c.Request.Context(), userID, teamIDs, includeArchived)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
if projects == nil {
projects = []models.Project{}
}
c.JSON(http.StatusOK, gin.H{"data": projects})
}
func (h *ProjectHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req createProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p := &models.Project{
Name: req.Name,
Description: req.Description,
Color: req.Color,
Icon: req.Icon,
Scope: models.ScopePersonal,
OwnerID: userID,
}
if err := h.stores.Projects.Create(c.Request.Context(), p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create project"})
return
}
c.JSON(http.StatusCreated, p)
}
func (h *ProjectHandler) Get(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
c.JSON(http.StatusOK, project)
}
func (h *ProjectHandler) Update(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req updateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.Update(c.Request.Context(), project.ID, req); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update project"})
return
}
// Return refreshed project
updated, err := h.stores.Projects.GetByID(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload project"})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *ProjectHandler) Delete(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Only owner or admin can delete
userID := getUserID(c)
if project.OwnerID != userID {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "only the project owner can delete it"})
return
}
}
if err := h.stores.Projects.Delete(c.Request.Context(), project.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete project"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "project deleted"})
}
// ── Channel Association ─────────────────────
func (h *ProjectHandler) AddChannel(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req addChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify the user owns the channel
userID := getUserID(c)
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), req.ChannelID, userID)
if err != nil || !owns {
c.JSON(http.StatusForbidden, gin.H{"error": "channel not found or not owned"})
return
}
if err := h.stores.Projects.AddChannel(c.Request.Context(), project.ID, req.ChannelID, req.Position); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add channel"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "channel added"})
}
func (h *ProjectHandler) RemoveChannel(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
channelID := c.Param("channelId")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id required"})
return
}
if err := h.stores.Projects.RemoveChannel(c.Request.Context(), project.ID, channelID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not in project"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove channel"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "channel removed"})
}
func (h *ProjectHandler) ListChannels(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
channels, err := h.stores.Projects.ListChannels(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
if channels == nil {
channels = []models.ProjectChannel{}
}
c.JSON(http.StatusOK, gin.H{"data": channels})
}
func (h *ProjectHandler) ReorderChannels(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req reorderChannelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.ReorderChannels(c.Request.Context(), project.ID, req.ChannelIDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder channels"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "channels reordered"})
}
// ── KB Association ──────────────────────────
func (h *ProjectHandler) AddKB(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req addKBRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.AddKB(c.Request.Context(), project.ID, req.KBID, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add KB"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "KB added"})
}
func (h *ProjectHandler) RemoveKB(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
kbID := c.Param("kbId")
if kbID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kb_id required"})
return
}
if err := h.stores.Projects.RemoveKB(c.Request.Context(), project.ID, kbID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "KB not in project"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove KB"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "KB removed"})
}
func (h *ProjectHandler) ListKBs(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
kbs, err := h.stores.Projects.ListKBs(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list KBs"})
return
}
if kbs == nil {
kbs = []models.ProjectKB{}
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// ── Note Association ────────────────────────
func (h *ProjectHandler) AddNote(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req addNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.AddNote(c.Request.Context(), project.ID, req.NoteID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add note"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "note added"})
}
func (h *ProjectHandler) RemoveNote(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
noteID := c.Param("noteId")
if noteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "note_id required"})
return
}
if err := h.stores.Projects.RemoveNote(c.Request.Context(), project.ID, noteID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "note not in project"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove note"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "note removed"})
}
func (h *ProjectHandler) ListNotes(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
notes, err := h.stores.Projects.ListNotes(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
if notes == nil {
notes = []models.ProjectNote{}
}
c.JSON(http.StatusOK, gin.H{"data": notes})
}
// ── Auth Helper ─────────────────────────────
// ── Admin ────────────────────────────────────
// AdminList returns all projects (no scope filtering). Admin only.
func (h *ProjectHandler) AdminList(c *gin.Context) {
ctx := c.Request.Context()
includeArchived := c.Query("include_archived") == "true"
projects, err := h.stores.Projects.AdminList(ctx, includeArchived)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
type adminProject struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Scope string `json:"scope"`
OwnerID string `json:"owner_id"`
TeamID *string `json:"team_id,omitempty"`
IsArchived bool `json:"is_archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ChannelCount int `json:"channel_count"`
KBCount int `json:"kb_count"`
NoteCount int `json:"note_count"`
OwnerName string `json:"owner_name"`
}
result := make([]adminProject, 0, len(projects))
for _, p := range projects {
result = append(result, adminProject{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Scope: p.Scope,
OwnerID: p.OwnerID,
TeamID: p.TeamID,
IsArchived: p.IsArchived,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ChannelCount: p.ChannelCount,
KBCount: p.KBCount,
NoteCount: p.NoteCount,
OwnerName: p.OwnerName,
})
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ── Auth Helper ─────────────────────────────
// loadAndAuthorize loads the project by :id param and checks user access.
// Returns (project, true) on success, or writes an error response and returns (nil, false).
func (h *ProjectHandler) loadAndAuthorize(c *gin.Context) (*models.Project, bool) {
projectID := c.Param("id")
if projectID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project id required"})
return nil, false
}
userID := getUserID(c)
// Admins can access all projects
if c.GetString("role") != "admin" {
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "access check failed"})
return nil, false
}
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return nil, false
}
}
project, err := h.stores.Projects.GetByID(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load project"})
return nil, false
}
return project, true
}

View File

@@ -1,373 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/models"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Project Test Harness ──────────────────
type projectHarness struct {
*testHarness
stores store.Stores
userToken string
userID string
adminToken string
adminID string
}
func setupProjectHarness(t *testing.T) *projectHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
projH := NewProjectHandler(stores)
protected.GET("/projects", projH.List)
protected.POST("/projects", projH.Create)
protected.GET("/projects/:id", projH.Get)
protected.PUT("/projects/:id", projH.Update)
protected.DELETE("/projects/:id", projH.Delete)
protected.POST("/projects/:id/channels", projH.AddChannel)
protected.DELETE("/projects/:id/channels/:channelId", projH.RemoveChannel)
protected.GET("/projects/:id/channels", projH.ListChannels)
protected.PUT("/projects/:id/channels/reorder", projH.ReorderChannels)
protected.POST("/projects/:id/knowledge-bases", projH.AddKB)
protected.DELETE("/projects/:id/knowledge-bases/:kbId", projH.RemoveKB)
protected.GET("/projects/:id/knowledge-bases", projH.ListKBs)
protected.POST("/projects/:id/notes", projH.AddNote)
protected.DELETE("/projects/:id/notes/:noteId", projH.RemoveNote)
protected.GET("/projects/:id/notes", projH.ListNotes)
// Admin routes
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin())
admin.GET("/projects", projH.AdminList)
admin.DELETE("/projects/:id", projH.Delete)
userID := database.SeedTestUser(t, "projuser", "projuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "projuser@test.com", "user")
adminID := database.SeedTestUser(t, "projadmin", "projadmin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, role = 'admin' WHERE id = $1"), adminID)
adminToken := makeToken(adminID, "projadmin@test.com", "admin")
return &projectHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
userToken: userToken,
userID: userID,
adminToken: adminToken,
adminID: adminID,
}
}
// seedProject creates a project via the store and returns it.
func (h *projectHarness) seedProject(name, ownerID string) *models.Project {
h.t.Helper()
p := &models.Project{
Name: name,
Scope: models.ScopePersonal,
OwnerID: ownerID,
}
if err := h.stores.Projects.Create(context.Background(), p); err != nil {
h.t.Fatalf("seedProject: %v", err)
}
return p
}
// ── GET /projects — envelope ──────────────
func TestProjects_List_Empty_Envelope(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/projects", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"]
if !ok {
t.Fatal("GET /projects must return {\"data\": [...]}, missing \"data\" key")
}
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("data should be an array, got %T", data)
}
if len(arr) != 0 {
t.Errorf("expected empty array, got %d items", len(arr))
}
}
func TestProjects_List_WithData_Shape(t *testing.T) {
h := setupProjectHarness(t)
h.seedProject("test-proj", h.userID)
resp := h.request("GET", "/api/v1/projects", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"].([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(data) != 1 {
t.Fatalf("expected 1 project, got %d", len(data))
}
proj := data[0].(map[string]interface{})
for _, key := range []string{"id", "name", "scope", "owner_id", "is_archived", "created_at", "updated_at"} {
if _, exists := proj[key]; !exists {
t.Errorf("missing field %q in project object", key)
}
}
if proj["name"] != "test-proj" {
t.Errorf("name: got %v, want test-proj", proj["name"])
}
}
// ── POST /projects — create ──────────────
func TestProjects_Create_201_Shape(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{
"name": "new-project",
"description": "test desc",
"color": "#ff0000",
"icon": "star",
})
if resp.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Bare object, not wrapped in "data"
if _, exists := body["data"]; exists {
t.Error("POST /projects should return bare object, not wrapped in 'data'")
}
if body["name"] != "new-project" {
t.Errorf("name: got %v, want new-project", body["name"])
}
if body["scope"] != "personal" {
t.Errorf("scope should be forced to personal, got %v", body["scope"])
}
if body["owner_id"] != h.userID {
t.Errorf("owner_id: got %v, want %s", body["owner_id"], h.userID)
}
if body["color"] != "#ff0000" {
t.Errorf("color: got %v, want #ff0000", body["color"])
}
if body["icon"] != "star" {
t.Errorf("icon: got %v, want star", body["icon"])
}
}
func TestProjects_Create_RequiresName(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{
"description": "no name",
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing name, got %d", resp.Code)
}
}
// ── GET /projects/:id — single object ─────
func TestProjects_Get_Shape(t *testing.T) {
h := setupProjectHarness(t)
p := h.seedProject("detail-proj", h.userID)
resp := h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Bare object
if _, exists := body["data"]; exists {
t.Error("GET /:id should return object directly, not wrapped in 'data'")
}
if body["id"] != p.ID {
t.Errorf("id: got %v, want %s", body["id"], p.ID)
}
// Computed counts use omitempty — absent when zero, present as number when nonzero.
// With a fresh project all counts are 0, so they'll be omitted. Verify that if
// present they're numeric (the AdminList test covers nonzero count visibility).
for _, key := range []string{"channel_count", "kb_count", "note_count"} {
if val, exists := body[key]; exists {
if _, ok := val.(float64); !ok {
t.Errorf("%s should be numeric, got %T", key, val)
}
}
}
}
func TestProjects_Get_NotFound(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/projects/00000000-0000-0000-0000-000000000000", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.Code)
}
}
// ── PUT /projects/:id — update ────────────
func TestProjects_Update_ReturnsRefreshed(t *testing.T) {
h := setupProjectHarness(t)
p := h.seedProject("old-name", h.userID)
resp := h.request("PUT", "/api/v1/projects/"+p.ID, h.userToken, map[string]interface{}{
"name": "new-name",
})
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
if body["name"] != "new-name" {
t.Errorf("name should be updated to new-name, got %v", body["name"])
}
if body["id"] != p.ID {
t.Errorf("id mismatch: got %v, want %s", body["id"], p.ID)
}
}
// ── DELETE /projects/:id ──────────────────
func TestProjects_Delete_Shape(t *testing.T) {
h := setupProjectHarness(t)
p := h.seedProject("doomed", h.userID)
resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
if body["message"] != "project deleted" {
t.Errorf("expected message 'project deleted', got %v", body["message"])
}
// Confirm actually deleted
resp = h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Errorf("project should be gone, got %d", resp.Code)
}
}
func TestProjects_Delete_ForbiddenForNonOwner(t *testing.T) {
h := setupProjectHarness(t)
otherID := database.SeedTestUser(t, "other", "other@test.com")
p := h.seedProject("others-proj", otherID)
resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil)
// Non-owner, non-admin user should not be able to access (404 from loadAndAuthorize)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404 for other user's personal project, got %d", resp.Code)
}
}
// ── Auth ──────────────────────────────────
func TestProjects_RequiresAuth(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/projects", "", nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.Code)
}
}
// ── Admin List ────────────────────────────
func TestProjects_AdminList_Envelope(t *testing.T) {
h := setupProjectHarness(t)
h.seedProject("admin-visible", h.userID)
resp := h.request("GET", "/api/v1/admin/projects", h.adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"].([]interface{})
if !ok {
t.Fatal("admin list must return {\"data\": [...]}")
}
if len(data) < 1 {
t.Fatal("expected at least 1 project in admin list")
}
proj := data[0].(map[string]interface{})
// Admin list has extra owner_name field
if _, exists := proj["owner_name"]; !exists {
t.Error("admin list project should include owner_name")
}
for _, key := range []string{"channel_count", "kb_count", "note_count"} {
if _, exists := proj[key]; !exists {
t.Errorf("admin list missing computed field %q", key)
}
}
}
func TestProjects_AdminList_ForbiddenForUser(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/admin/projects", h.userToken, nil)
if resp.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin, got %d", resp.Code)
}
}

View File

@@ -1,51 +0,0 @@
// Package handlers — provider_resolver_adapter.go
//
// v0.29.1 CS2: Adapter that satisfies sandbox.ProviderResolver using
// ResolveProviderConfig + providers.Get. Avoids circular dependency
// (sandbox → handlers → sandbox) by having handlers implement the
// sandbox-defined interface.
package handlers
import (
"context"
"fmt"
"switchboard-core/crypto"
"switchboard-core/providers"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// ProviderResolverAdapter implements sandbox.ProviderResolver using
// the existing ResolveProviderConfig function and provider registry.
type ProviderResolverAdapter struct {
stores store.Stores
vault *crypto.KeyResolver
}
// NewProviderResolverAdapter creates an adapter for Starlark provider resolution.
func NewProviderResolverAdapter(stores store.Stores, vault *crypto.KeyResolver) *ProviderResolverAdapter {
return &ProviderResolverAdapter{stores: stores, vault: vault}
}
// Resolve satisfies sandbox.ProviderResolver. It resolves the provider
// config via the BYOK chain and returns the provider instance + config.
func (a *ProviderResolverAdapter) Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*sandbox.ProviderResolution, error) {
res, err := ResolveProviderConfig(a.stores, a.vault, userID, channelID, providerConfigID, model)
if err != nil {
return nil, err
}
provider, err := providers.Get(res.ProviderID)
if err != nil {
return nil, fmt.Errorf("provider unavailable: %w", err)
}
return &sandbox.ProviderResolution{
Provider: provider,
Config: res.Config,
ProviderID: res.ProviderID,
Model: res.Model,
ConfigID: res.ConfigID,
}, nil
}

View File

@@ -1,306 +0,0 @@
// Package handlers — resolve.go
//
// v0.27.2: Extracted provider resolution and tool definition building
// from CompletionHandler methods to standalone functions. Both the HTTP
// completion handler and the headless task scheduler need these paths.
//
// The CompletionHandler methods (resolveConfig, buildToolDefs) remain as
// thin wrappers for backward compat — existing callers are unchanged.
//
// v0.29.0-cs7a: Replaced raw SQL with store methods.
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"switchboard-core/crypto"
"switchboard-core/providers"
"switchboard-core/store"
"switchboard-core/tools"
)
// ── Provider Resolution ────────────────────────
// ProviderResolution holds the resolved provider configuration and metadata.
type ProviderResolution struct {
Config providers.ProviderConfig
ProviderID string // e.g. "anthropic", "openai"
Model string // resolved model ID
ConfigID string // provider_configs.id
ProviderScope string // "personal", "team", "global"
}
// ResolveProviderConfig resolves the provider configuration for a completion
// request. Resolution order:
// 1. Explicit providerConfigID (from request or task definition)
// 2. Channel's configured provider (provider_config_id on channels table)
// 3. User's first active config (personal first, then global)
//
// The vault is used to decrypt API keys. Pass nil for unencrypted fallback.
func ResolveProviderConfig(
stores store.Stores,
vault *crypto.KeyResolver,
userID, channelID, providerConfigID, modelID string,
) (ProviderResolution, error) {
ctx := context.Background()
configID := providerConfigID
// 2. Config from channel
if configID == "" && channelID != "" {
channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
}
}
// 3. User's first active config (personal first, then global — excludes team providers)
if configID == "" {
var err error
configID, err = stores.Providers.FindFirstForUser(ctx, userID)
if err != nil {
return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs")
}
}
// Load the config — allow personal, global, OR team configs the user belongs to
cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID)
if err == sql.ErrNoRows {
return ProviderResolution{}, fmt.Errorf("API config not found or not accessible")
}
if err != nil {
return ProviderResolution{}, fmt.Errorf("failed to load API config: %w", err)
}
// Resolve model: explicit > config default
model := modelID
if model == "" && cfg.ModelDefault != "" {
model = cfg.ModelDefault
}
if model == "" {
return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config")
}
// Decrypt API key using the appropriate tier
key := ""
if len(cfg.APIKeyEnc) > 0 {
if vault != nil {
var err error
key, err = vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
if err == crypto.ErrVaultLocked {
return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again")
}
return ProviderResolution{}, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
key = string(cfg.APIKeyEnc)
}
}
// Parse custom headers
customHeaders := make(map[string]string)
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
// Parse provider-specific settings
providerSettings := make(map[string]interface{})
if cfg.Settings != nil {
for k, v := range cfg.Settings {
providerSettings[k] = v
}
}
proxyMode := cfg.ProxyMode
if proxyMode == "" {
proxyMode = "system"
}
proxyURLStr := ""
if cfg.ProxyURL != nil {
proxyURLStr = *cfg.ProxyURL
}
return ProviderResolution{
Config: providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
ProxyMode: proxyMode,
ProxyURL: proxyURLStr,
},
ProviderID: cfg.Provider,
Model: model,
ConfigID: cfg.ID,
ProviderScope: cfg.Scope,
}, nil
}
// ── Tool Definition Building ───────────────────
// BuildToolDefs assembles the tool definitions for a completion request.
// Includes server-registered tools filtered by context predicates,
// browser extension tools (if includeBrowser), and persona tool grant
// allowlisting.
//
// Additional tool grant filtering (e.g. task-level grants) can be applied
// by the caller after this function returns.
func BuildToolDefs(
ctx context.Context,
stores store.Stores,
userID string,
includeBrowser bool,
disabledTools []string,
tctx tools.ToolContext,
personaID string,
) []providers.ToolDef {
// Build disabled set for O(1) lookup
disabled := make(map[string]bool, len(disabledTools))
for _, name := range disabledTools {
disabled[name] = true
}
// v0.25.0: Tools self-declare availability via predicates.
allTools := tools.AvailableFor(tctx, disabled)
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
// Append browser-defined and starlark-tier tool schemas from extensions.
// v0.29.2: starlark tools are always included (not gated on browser connection).
if stores.Packages != nil {
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, pkg := range pkgs {
// Browser tools only when a browser client is connected.
if pkg.Tier == "browser" && !includeBrowser {
continue
}
// Only browser and starlark tiers expose tools this way.
if pkg.Tier != "browser" && pkg.Tier != "starlark" {
continue
}
// Starlark packages must be active to expose tools.
if pkg.Tier == "starlark" && pkg.Status != "active" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"tools"`
}
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {
if disabled[t.Name] {
continue
}
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
}
// v0.25.0: Persona tool grants — second-pass allowlist.
// If the active persona has explicit tool grants, restrict to only those tools.
// Empty grants = persona inherits all context-available tools (backward compat).
if personaID != "" && stores.Personas != nil {
grants, err := stores.Personas.GetToolGrants(ctx, personaID)
if err != nil {
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
} else if len(grants) > 0 {
allowed := make(map[string]bool, len(grants))
for _, g := range grants {
allowed[g] = true
}
filtered := defs[:0]
for _, d := range defs {
if allowed[d.Function.Name] {
filtered = append(filtered, d)
}
}
defs = filtered
}
}
return defs
}
// BuildExtToolMap returns a map of toolName → PackageRegistration for all
// active starlark-tier extensions that declare tools in their manifest.
// Used by the tool loop to dispatch matched calls to on_tool_call.
func BuildExtToolMap(ctx context.Context, stores store.Stores, userID string) map[string]*store.PackageRegistration {
if stores.Packages == nil {
return nil
}
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
return nil
}
result := make(map[string]*store.PackageRegistration)
for i, pkg := range pkgs {
if pkg.Tier != "starlark" || pkg.Status != "active" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if json.Unmarshal(marshalManifest(pkg.Manifest), &manifest) != nil {
continue
}
for _, t := range manifest.Tools {
if t.Name != "" {
result[t.Name] = &pkgs[i].PackageRegistration
}
}
}
return result
}
// FilterToolDefsByGrants applies an additional allowlist to tool defs.
// Used by the task scheduler to enforce task-level tool grants on top
// of persona-level grants. Passing nil or empty grants returns defs unchanged.
func FilterToolDefsByGrants(defs []providers.ToolDef, grants []string) []providers.ToolDef {
if len(grants) == 0 {
return defs
}
allowed := make(map[string]bool, len(grants))
for _, g := range grants {
allowed[g] = true
}
filtered := make([]providers.ToolDef, 0, len(defs))
for _, d := range defs {
if allowed[d.Function.Name] {
filtered = append(filtered, d)
}
}
return filtered
}

View File

@@ -1,243 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
)
// RolesHandler manages model role configuration.
type RolesHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewRolesHandler creates a roles handler.
func NewRolesHandler(s store.Stores, resolver *roles.Resolver) *RolesHandler {
return &RolesHandler{stores: s, resolver: resolver}
}
// ── List All Role Configs ──────────────────
// GET /admin/roles
func (h *RolesHandler) ListRoles(c *gin.Context) {
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
return
}
c.JSON(http.StatusOK, allRoles)
}
// ── Get Single Role Config ─────────────────
// GET /admin/roles/:role
func (h *RolesHandler) GetRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, "", nil)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
// ── Update Role Config ─────────────────────
// PUT /admin/roles/:role
func (h *RolesHandler) UpdateRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
var req roles.RoleConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Load current global model_roles
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
return
}
if allRoles == nil {
allRoles = models.JSONMap{}
}
// Update the specific role
allRoles[role] = req
// Persist
userID := getUserID(c)
if err := h.stores.GlobalConfig.Set(c.Request.Context(), "model_roles", allRoles, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save role"})
return
}
c.JSON(http.StatusOK, req)
}
// ── Test Role ──────────────────────────────
// POST /admin/roles/:role/test
func (h *RolesHandler) TestRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
if role == roles.RoleEmbedding {
// Test embedding
result, err := h.resolver.Embed(c.Request.Context(), role, "", nil, []string{"test embedding"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"model": result.Model,
"provider": result.ProviderID,
"dimensions": len(result.Embeddings[0]),
"used_fallback": result.UsedFallback,
})
return
}
// Test completion with a minimal prompt
result, err := h.resolver.Complete(c.Request.Context(), role, "", nil, []providers.Message{
{Role: "user", Content: "Say 'ok' and nothing else."},
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"model": result.Model,
"provider": result.ProviderID,
"content": result.Content,
"input_tokens": result.InputTokens,
"output_tokens": result.OutputTokens,
"used_fallback": result.UsedFallback,
})
}
// ── Team Role Overrides ────────────────────
// ListTeamRoles returns role overrides for a specific team.
// GET /teams/:teamId/roles
func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
teamID := c.Param("teamId")
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
roleOverrides := make(map[string]interface{})
if team.Settings != nil {
if raw, ok := team.Settings["model_roles"]; ok {
if m, ok := raw.(map[string]interface{}); ok {
roleOverrides = m
}
}
}
c.JSON(http.StatusOK, roleOverrides)
}
// UpdateTeamRole sets a team role override.
// PUT /teams/:teamId/roles/:role
func (h *RolesHandler) UpdateTeamRole(c *gin.Context) {
teamID := c.Param("teamId")
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
var req roles.RoleConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
settings := team.Settings
if settings == nil {
settings = models.JSONMap{}
}
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
if roleOverrides == nil {
roleOverrides = make(map[string]interface{})
}
roleOverrides[role] = req
settings["model_roles"] = roleOverrides
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
"settings": settings,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save team role"})
return
}
c.JSON(http.StatusOK, req)
}
// DeleteTeamRole removes a team role override (falls back to global).
// DELETE /teams/:teamId/roles/:role
func (h *RolesHandler) DeleteTeamRole(c *gin.Context) {
teamID := c.Param("teamId")
role := c.Param("role")
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
settings := team.Settings
if settings == nil {
c.JSON(http.StatusOK, gin.H{"message": "no override to remove"})
return
}
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
if roleOverrides != nil {
delete(roleOverrides, role)
settings["model_roles"] = roleOverrides
}
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
"settings": settings,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove team role"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "override removed"})
}

View File

@@ -1,242 +0,0 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/routing"
"switchboard-core/store"
)
// ── Routing Admin Handler ───────────────────
type RoutingAdminHandler struct {
stores store.Stores
evaluator *routing.Evaluator
healthStore health.Store
}
func NewRoutingAdminHandler(stores store.Stores, evaluator *routing.Evaluator, hs health.Store) *RoutingAdminHandler {
return &RoutingAdminHandler{stores: stores, evaluator: evaluator, healthStore: hs}
}
// ── CRUD ────────────────────────────────────
// ListPolicies returns all routing policies.
// GET /api/v1/admin/routing/policies
func (h *RoutingAdminHandler) ListPolicies(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusOK, gin.H{"data": []models.RoutingPolicy{}})
return
}
policies, err := h.stores.RoutingPolicies.ListAll(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list policies: " + err.Error()})
return
}
if policies == nil {
policies = []models.RoutingPolicy{}
}
c.JSON(http.StatusOK, gin.H{"data": policies})
}
// GetPolicy returns a single routing policy.
// GET /api/v1/admin/routing/policies/:id
func (h *RoutingAdminHandler) GetPolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "routing not available"})
return
}
p, err := h.stores.RoutingPolicies.GetByID(context.Background(), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
return
}
c.JSON(http.StatusOK, p)
}
// CreatePolicy creates a new routing policy.
// POST /api/v1/admin/routing/policies
func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
var p models.RoutingPolicy
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate policy type
switch routing.PolicyType(p.Type) {
case routing.PolicyProviderPrefer, routing.PolicyTeamRoute,
routing.PolicyCostLimit, routing.PolicyModelAlias,
routing.PolicyCapabilityMatch:
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type})
return
}
// Validate scope
if p.Scope != "global" && p.Scope != "team" {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be 'global' or 'team'"})
return
}
if p.Scope == "team" && (p.TeamID == nil || *p.TeamID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped policies"})
return
}
if err := h.stores.RoutingPolicies.Create(context.Background(), &p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create policy: " + err.Error()})
return
}
c.JSON(http.StatusCreated, p)
}
// UpdatePolicy updates an existing routing policy.
// PUT /api/v1/admin/routing/policies/:id
func (h *RoutingAdminHandler) UpdatePolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
id := c.Param("id")
// Verify exists
if _, err := h.stores.RoutingPolicies.GetByID(context.Background(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
return
}
var p models.RoutingPolicy
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.ID = id
if err := h.stores.RoutingPolicies.Update(context.Background(), &p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy: " + err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
// DeletePolicy removes a routing policy.
// DELETE /api/v1/admin/routing/policies/:id
func (h *RoutingAdminHandler) DeletePolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
if err := h.stores.RoutingPolicies.Delete(context.Background(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete policy: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ── Dry-Run Test ────────────────────────────
type routingTestRequest struct {
Model string `json:"model" binding:"required"`
UserID string `json:"user_id" binding:"required"`
TeamIDs []string `json:"team_ids,omitempty"`
}
// TestRouting performs a dry-run policy evaluation for a given model + user.
// POST /api/v1/admin/routing/test
func (h *RoutingAdminHandler) TestRouting(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
var req routingTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Load active policies
dbPolicies, err := h.stores.RoutingPolicies.ListActive(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load policies"})
return
}
policies := routing.FromModels(dbPolicies)
// Build candidates from all active provider configs
candidates, err := h.buildCandidates(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build candidates: " + err.Error()})
return
}
// Build health status
healthStatus := make(map[string]models.ProviderStatus)
if h.healthStore != nil {
windows, _ := h.healthStore.ListAllCurrent(context.Background())
for _, w := range windows {
healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
}
}
ctx := &routing.Context{
UserID: req.UserID,
TeamIDs: req.TeamIDs,
Model: req.Model,
HealthStatus: healthStatus,
Pricing: map[string]*models.ModelPricing{},
}
result, decision := h.evaluator.Evaluate(ctx, policies, candidates)
c.JSON(http.StatusOK, gin.H{
"candidates": result,
"decision": decision,
"policies": len(policies),
})
}
// buildCandidates creates routing candidates from all active provider configs.
func (h *RoutingAdminHandler) buildCandidates(c *gin.Context) ([]routing.Candidate, error) {
if h.stores.Providers == nil {
return nil, nil
}
// For dry-run, list all global configs (admin scope).
configs, err := h.stores.Providers.ListGlobal(context.Background())
if err != nil {
return nil, err
}
candidates := make([]routing.Candidate, 0, len(configs))
for _, cfg := range configs {
if !cfg.IsActive {
continue
}
candidates = append(candidates, routing.Candidate{
ConfigID: cfg.ID,
ProviderID: cfg.Provider,
Model: "", // model is applied per-policy or from request
Endpoint: cfg.Endpoint,
})
}
return candidates, nil
}

View File

@@ -1,141 +0,0 @@
package handlers
import (
"context"
"log"
"strings"
"switchboard-core/config"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
)
// Known provider default endpoints for seeding.
var seedDefaultEndpoints = map[string]string{
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"openrouter": "https://openrouter.ai/api/v1",
"venice": "https://api.venice.ai/api/v1",
"mistral": "https://api.mistral.ai/v1",
"groq": "https://api.groq.com/openai/v1",
"together": "https://api.together.xyz/v1",
"fireworks": "https://api.fireworks.ai/inference/v1",
"deepseek": "https://api.deepseek.com/v1",
"perplexity": "https://api.perplexity.ai",
}
// SeedProviders creates global providers from the SEED_PROVIDERS env var.
//
// Format: "provider:api_key[:name],provider:api_key[:name]"
//
// Examples:
//
// SEED_PROVIDERS="openai:sk-xxx,anthropic:sk-ant-xxx"
// SEED_PROVIDERS="openai:sk-xxx:My OpenAI,openrouter:sk-or-xxx"
//
// Idempotent: skips if a global provider with the same name already exists.
// Blocked in production.
func SeedProviders(cfg *config.Config, stores store.Stores, resolver *crypto.KeyResolver) {
if cfg.SeedProviders == "" {
return
}
if cfg.Environment == "production" {
log.Printf("⚠ SEED_PROVIDERS ignored in production environment")
return
}
ctx := context.Background()
entries := strings.Split(cfg.SeedProviders, ",")
log.Printf(" 🌱 SEED_PROVIDERS: %d entries to process", len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, ":", 3)
if len(parts) < 2 {
log.Printf("⚠ Seed provider skipped (bad format, want provider:key[:name]): %q", entry)
continue
}
provider := strings.ToLower(strings.TrimSpace(parts[0]))
apiKey := strings.TrimSpace(parts[1])
// Derive name: explicit or "OpenAI (seed)", "Anthropic (seed)", etc.
name := ""
if len(parts) >= 3 {
name = strings.TrimSpace(parts[2])
}
if name == "" {
// Capitalize provider name
name = strings.ToUpper(provider[:1]) + provider[1:] + " (seed)"
}
// Resolve endpoint
endpoint, ok := seedDefaultEndpoints[provider]
if !ok {
// Unknown provider — treat as openai-compatible with custom endpoint
log.Printf("⚠ Seed provider '%s': unknown provider, skipping (no default endpoint)", provider)
continue
}
if apiKey == "" {
log.Printf("⚠ Seed provider '%s': empty API key, skipping", name)
continue
}
// Check if already exists (idempotent)
existing, _ := stores.Providers.ListGlobal(ctx)
found := false
for _, p := range existing {
if p.Name == name || (p.Provider == provider && p.Scope == models.ScopeGlobal) {
found = true
break
}
}
if found {
log.Printf(" 🌱 Seed provider '%s' already exists, skipping", name)
continue
}
// Create provider config
pcfg := &models.ProviderConfig{
Name: name,
Provider: provider,
Endpoint: endpoint,
Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true,
}
// Encrypt API key
if resolver != nil {
enc, nonce, err := resolver.EncryptForScope(apiKey, "global", "")
if err != nil {
log.Printf("⚠ Seed provider '%s': encrypt failed: %v", name, err)
continue
}
pcfg.APIKeyEnc = enc
pcfg.KeyNonce = nonce
} else {
pcfg.APIKeyEnc = []byte(apiKey)
}
if err := stores.Providers.Create(ctx, pcfg); err != nil {
log.Printf("⚠ Seed provider '%s': create failed: %v", name, err)
continue
}
// Auto-fetch and enable models
result, err := syncAndEnableProviderModels(ctx, stores, pcfg, apiKey)
if err != nil {
log.Printf(" 🌱 Seed provider '%s' created (model fetch failed: %v)", name, err)
} else {
log.Printf(" 🌱 Seed provider '%s' created (%d models fetched)", name, result.Total)
}
}
}

View File

@@ -1,203 +0,0 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/events"
"switchboard-core/metrics"
"switchboard-core/providers"
"switchboard-core/sandbox"
"switchboard-core/store"
"switchboard-core/tools"
)
// recordHealthFn records provider health from standalone streaming functions
// and updates Prometheus completion metrics (v0.33.0).
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
duration := time.Since(start)
latencyMs := int(duration.Milliseconds())
if configID != "" {
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
}
if hr == nil || configID == "" {
return
}
if err != nil {
errMsg := err.Error()
status := "error"
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
hr.RecordRateLimit(configID, latencyMs, errMsg)
status = "rate_limited"
} else {
hr.RecordError(configID, latencyMs, errMsg)
}
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
} else {
hr.RecordSuccess(configID, latencyMs)
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
}
}
// streamWithToolLoop is the SSE streaming entry point for single-model
// completion with tool execution. Sets SSE headers, delegates to
// CoreToolLoop with an sseSink, and returns the accumulated result.
//
// Used by:
// - streamCompletion (normal chat — persists via persistMessage)
// - Regenerate (regen/edit — persists as sibling with tree metadata)
func streamWithToolLoop(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
runner *sandbox.Runner,
extTools map[string]*store.PackageRegistration,
) LoopResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
sink := newSSESink(c, model)
return CoreToolLoop(c.Request.Context(), LoopConfig{
Provider: provider,
Cfg: cfg,
Req: req,
Model: model,
ProviderType: providerType,
ExecCtx: tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
TeamID: teamID,
},
Hub: hub,
Health: health,
ConfigID: configID,
Budget: LoopBudget{},
Streaming: true,
Runner: runner,
ExtTools: extTools,
}, sink)
}
const browserToolTimeout = 30 * time.Second
// streamModelResponse is the multi-model streaming variant. Runs on an
// already-established SSE connection — does NOT set SSE headers or send
// [DONE]. SSE deltas include a "model_display" field for frontend attribution.
func streamModelResponse(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
runner *sandbox.Runner,
extTools map[string]*store.PackageRegistration,
) LoopResult {
sink := newSSEModelSink(c, model, displayName)
return CoreToolLoop(c.Request.Context(), LoopConfig{
Provider: provider,
Cfg: cfg,
Req: req,
Model: model,
ProviderType: providerType,
ExecCtx: tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
TeamID: teamID,
},
Hub: hub,
Health: health,
ConfigID: configID,
Budget: LoopBudget{},
Streaming: true,
Runner: runner,
ExtTools: extTools,
}, sink)
}
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
b := make([]byte, 16)
rand.Read(b)
callID := hex.EncodeToString(b)
// Publish tool.call event to the user's browser
payload, _ := json.Marshal(map[string]interface{}{
"call_id": callID,
"tool": call.Name,
"arguments": json.RawMessage(call.Arguments),
})
hub.PublishToUser(userID, events.Event{
Label: "tool.call." + callID,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
// Block until browser sends tool.result.{callID} or timeout
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
if !ok {
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
IsError: true,
}
}
// Parse result from the browser event payload
var result struct {
CallID string `json:"call_id"`
Result string `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(event.Payload, &result); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid result from browser tool"}`,
IsError: true,
}
}
if result.Error != "" {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Error,
IsError: true,
}
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Result,
}
}

View File

@@ -1,93 +0,0 @@
package handlers
// summarize.go — Manual conversation summarization via HTTP.
//
// v0.29.0: Raw SQL replaced with ChannelStore methods.
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/compaction"
"switchboard-core/roles"
"switchboard-core/store"
)
// SummarizeHandler handles manual conversation summarization via HTTP.
type SummarizeHandler struct {
stores store.Stores
compaction *compaction.Service
}
// NewSummarizeHandler creates a new handler backed by the compaction service.
func NewSummarizeHandler(stores store.Stores, svc *compaction.Service) *SummarizeHandler {
return &SummarizeHandler{stores: stores, compaction: svc}
}
// ── Summarize & Continue ──────────────────
// POST /channels/:id/summarize
func (h *SummarizeHandler) Summarize(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// ── Verify channel ownership ──
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
if err != nil || ch == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ch.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// ── Check utility role is configured ──
resolver := h.compaction.Resolver()
if !resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) {
if !resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.",
})
return
}
}
// ── Rate limiting (org-funded calls only) ──
isPersonal := resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility)
if !isPersonal {
if err := h.compaction.CheckRateLimit(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return
}
}
// ── Compact ──
teamID := h.compaction.GetUserTeamID(c.Request.Context(), userID)
result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: teamID,
Trigger: "manual",
})
if err != nil {
switch err.Error() {
case "conversation too short to summarize":
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case "not enough new messages since last summary":
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{
"summary_id": result.SummaryID,
"summarized_count": result.SummarizedCount,
"model": result.Model,
"used_fallback": result.UsedFallback,
"content": result.Content,
})
}

View File

@@ -1,373 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/store"
)
// ── Team Provider Handlers ──────────────────
// ListTeamProviders returns API configs scoped to a team.
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
teamID := getTeamID(c)
ctx := c.Request.Context()
configs, err := h.stores.Providers.ListAllForTeam(ctx, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"})
return
}
type teamProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"`
ModelDefault *string `json:"model_default"`
Config map[string]interface{} `json:"config"`
IsActive bool `json:"is_active"`
IsPrivate bool `json:"is_private"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
result := make([]teamProvider, 0, len(configs))
for _, cfg := range configs {
var md *string
if cfg.ModelDefault != "" {
md = &cfg.ModelDefault
}
cfgMap := map[string]interface{}{}
if cfg.Config != nil {
cfgMap = cfg.Config
}
result = append(result, teamProvider{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.HasKey(),
ModelDefault: md,
Config: cfgMap,
IsActive: cfg.IsActive,
IsPrivate: cfg.IsPrivate,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
c.JSON(http.StatusOK, gin.H{
"data": result,
"allow_team_providers": isTeamProvidersAllowed(h.stores, teamID),
})
}
// CreateTeamProvider creates an API config scoped to a team.
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
if !isTeamProvidersAllowed(h.stores, teamID) {
c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"})
return
}
var req struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := providers.Get(req.Provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported provider: " + req.Provider,
"supported_providers": providers.List(),
})
return
}
// Encrypt the API key for team scope
var apiKeyEnc, keyNonce []byte
if req.APIKey != "" {
if h.vault != nil {
var err error
apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
} else {
apiKeyEnc = []byte(req.APIKey)
}
}
headersMap := models.JSONMap{}
if req.Headers != nil {
for k, v := range req.Headers {
headersMap[k] = v
}
}
cfg := &models.ProviderConfig{
Scope: models.ScopeTeam,
OwnerID: &teamID,
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: apiKeyEnc,
KeyNonce: keyNonce,
KeyScope: "team",
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: headersMap,
IsActive: true,
IsPrivate: req.IsPrivate,
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
log.Printf("[WARN] Failed to create team provider: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": cfg.ID})
}
// UpdateTeamProvider updates a team-scoped API config.
func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
ctx := c.Request.Context()
var req struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify provider belongs to this team
existing, err := h.stores.Providers.GetByID(ctx, providerID)
if err != nil || existing.Scope != models.ScopeTeam || (existing.OwnerID != nil && *existing.OwnerID != teamID) {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
// Build patch
patch := models.ProviderConfigPatch{}
fieldCount := 0
if req.Name != nil {
patch.Name = req.Name
fieldCount++
}
if req.Endpoint != nil {
patch.Endpoint = req.Endpoint
fieldCount++
}
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
fieldCount++
}
if req.ModelDefault != nil {
patch.ModelDefault = req.ModelDefault
fieldCount++
}
if req.IsActive != nil {
patch.IsActive = req.IsActive
fieldCount++
}
if req.IsPrivate != nil {
patch.IsPrivate = req.IsPrivate
fieldCount++
}
if req.Config != nil {
patch.Config = models.JSONMap(req.Config)
fieldCount++
}
if req.Headers != nil {
headersMap := models.JSONMap{}
for k, v := range req.Headers {
headersMap[k] = v
}
patch.Headers = headersMap
fieldCount++
}
if fieldCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"})
return
}
c.JSON(http.StatusOK, gin.H{"id": providerID, "updated": true})
}
// DeleteTeamProvider removes a team-scoped API config.
func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
n, err := h.stores.Providers.DeleteByIDAndTeam(c.Request.Context(), providerID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ListTeamProviderModels lists models available from a team provider (live query).
func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
ctx := c.Request.Context()
cfg, err := h.stores.Providers.GetByID(ctx, providerID)
if err != nil || cfg.Scope != models.ScopeTeam || !cfg.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if cfg.OwnerID == nil || *cfg.OwnerID != teamID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
provider, err := providers.Get(cfg.Provider)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"})
return
}
key := ""
if cfg.HasKey() {
if h.vault != nil {
var err error
key, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"})
return
}
} else {
key = string(cfg.APIKeyEnc)
}
}
var customHeaders map[string]string
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
modelList, err := provider.ListModels(ctx, providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
type modelInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Capabilities models.ModelCapabilities `json:"capabilities"`
}
out := make([]modelInfo, 0, len(modelList))
for _, m := range modelList {
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps})
}
c.JSON(http.StatusOK, gin.H{"data": out, "provider": cfg.Name})
}
// parseJSONBConfig parses a JSONB text string into a map.
func parseJSONBConfig(raw string) map[string]interface{} {
if raw == "" || raw == "{}" || raw == "null" {
return map[string]interface{}{}
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]interface{}{}
}
return m
}
// isTeamProvidersAllowed checks if team providers are enabled.
func isTeamProvidersAllowed(stores store.Stores, teamID string) bool {
if stores.GlobalConfig == nil {
return false
}
ctx := context.Background()
// Check global setting
globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers")
if err == nil && globalVal == "false" {
return false
}
// Check team-level setting
team, err := stores.Teams.GetByID(ctx, teamID)
if err != nil {
return true // fail open
}
if team.Settings != nil {
if v, ok := team.Settings["allow_team_providers"]; ok {
if bVal, ok := v.(bool); ok {
return bVal
}
}
}
return true
}

View File

@@ -1,110 +0,0 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
)
// TitleHandler generates chat titles using the utility role.
type TitleHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewTitleHandler creates a title generation handler.
func NewTitleHandler(s store.Stores, r *roles.Resolver) *TitleHandler {
return &TitleHandler{stores: s, resolver: r}
}
// GenerateTitle generates a title for a channel using the utility role.
// POST /channels/:id/generate-title
func (h *TitleHandler) GenerateTitle(c *gin.Context) {
channelID := c.Param("id")
userID := c.GetString("user_id")
teamID := c.GetString("team_id")
// Verify channel access
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
if err != nil || !owns {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Load first few messages
msgs, err := h.stores.Messages.ListForChannel(c.Request.Context(), channelID, store.ListOptions{
Limit: 4, Order: "asc",
})
if err != nil || len(msgs) == 0 {
c.JSON(http.StatusOK, gin.H{"title": ch.Title}) // keep existing
return
}
// Build a snippet from the first messages
var snippet strings.Builder
for _, m := range msgs {
content := m.Content
if len(content) > 200 {
content = content[:200] + "…"
}
fmt.Fprintf(&snippet, "%s: %s\n", m.Role, content)
}
// Call utility role
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
var tID *string
if teamID != "" {
tID = &teamID
}
result, err := h.resolver.Complete(ctx, roles.RoleUtility, userID, tID, []providers.Message{
{
Role: "system",
Content: "You are a title generator. Given a conversation snippet, produce a concise title of 6 words or fewer. Respond with ONLY the title, no quotes, no punctuation at the end, no explanation.",
},
{
Role: "user",
Content: "Title this conversation:\n\n" + snippet.String(),
},
})
if err != nil {
// Fallback: truncate first user message
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
return
}
title := strings.TrimSpace(result.Content)
// Strip surrounding quotes if the model added them
title = strings.Trim(title, "\"'`\u201c\u201d\u2018\u2019")
if title == "" {
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
return
}
// Cap at 100 chars
if len(title) > 100 {
title = title[:100]
}
// Update channel
_ = h.stores.Channels.Update(c.Request.Context(), channelID, map[string]interface{}{
"title": title,
})
c.JSON(http.StatusOK, gin.H{"title": title})
}

View File

@@ -1,782 +0,0 @@
// Package handlers — tool_loop.go
//
// v0.27.2: Extracted core tool loop with sink abstraction.
//
// coreToolLoop is the single implementation of multi-round LLM completion
// with tool execution. All callers (SSE streaming, multi-model streaming,
// sync JSON, headless scheduler) provide a LoopSink for I/O and a LoopConfig
// for dependencies. The loop handles provider calls, tool dispatch (server +
// browser bridge), health recording, budget enforcement, and workspace events.
//
// Prior to this extraction, the same logic was duplicated in three places:
// - streamWithToolLoop (SSE streaming)
// - streamModelResponse (multi-model SSE)
// - syncCompletion (non-streaming JSON)
//
// Those functions are now thin wrappers over coreToolLoop.
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"switchboard-core/events"
"switchboard-core/providers"
"switchboard-core/sandbox"
"switchboard-core/store"
"switchboard-core/tools"
)
// ── Types ──────────────────────────────────────
// defaultMaxRounds is the safety limit on tool call iterations.
// Callers can override via LoopBudget.MaxRounds.
const defaultMaxRounds = 10
// LoopResult holds the accumulated output from a completion with tool
// execution. Callers are responsible for persistence.
// FileRef tracks a file created by a tool for auto-linking to the assistant message.
type FileRef struct {
WorkspaceID string `json:"workspace_id"`
Path string `json:"path"`
}
type LoopResult struct {
Content string
ToolActivity []map[string]interface{}
FileRefs []FileRef // v0.37.18: workspace files created by tools
InputTokens int
OutputTokens int
CacheCreationTokens int
CacheReadTokens int
ToolCallCount int // total tool calls across all rounds
BudgetExceeded string // "" | "tokens" | "tool_calls" | "max_rounds"
Error error // non-nil if the loop terminated due to a provider error
}
// LoopBudget controls resource limits for the tool loop.
// Zero values mean "use default" for MaxRounds and "unlimited" for the rest.
type LoopBudget struct {
MaxRounds int // max tool-call iterations; 0 = defaultMaxRounds
MaxToolCalls int // total tool calls across all rounds; 0 = unlimited
MaxTokens int // total input+output tokens; 0 = unlimited
}
func (b LoopBudget) maxRounds() int {
if b.MaxRounds > 0 {
return b.MaxRounds
}
return defaultMaxRounds
}
// LoopConfig bundles all dependencies for the core tool loop.
type LoopConfig struct {
Provider providers.Provider
Cfg providers.ProviderConfig
Req *providers.CompletionRequest
Model string
ProviderType string
ExecCtx tools.ExecutionContext
Hub *events.Hub // nil for headless (scheduler)
Health HealthRecorder // nil for headless
ConfigID string
Budget LoopBudget
Streaming bool // true = StreamCompletion, false = ChatCompletion
// v0.29.2: extension tool dispatch (nil = no extension tools)
Runner *sandbox.Runner
ExtTools map[string]*store.PackageRegistration // toolName → package
}
// ── Sink Interface ─────────────────────────────
// LoopSink receives events from the core tool loop for I/O.
// Implementations control what happens with each event: write SSE to
// a gin.Context, accumulate silently, log to stdout, etc.
type LoopSink interface {
// OnDelta receives a text content delta from the LLM.
OnDelta(delta string)
// OnReasoning receives a reasoning/thinking delta from the LLM.
OnReasoning(delta string)
// OnError receives a fatal error (provider failure, stream error).
OnError(err error)
// OnToolUse is called when the LLM requests tool calls.
OnToolUse(calls []providers.ToolCall)
// OnToolResult is called after each tool execution completes.
OnToolResult(result map[string]interface{})
// OnFinish is called when the LLM produces a finish_reason.
OnFinish(reason string)
// OnDone signals end of the entire completion (after finish or budget breach).
OnDone()
// OnMaxRounds is called when the loop hits its iteration limit.
OnMaxRounds()
}
// ── Core Tool Loop ─────────────────────────────
// coreToolLoop is the single, canonical tool-loop implementation.
// It drives multi-round LLM completion with tool execution, budget
// enforcement, and health recording. I/O is delegated to the sink.
//
// The caller is responsible for:
// - Setting SSE headers (if streaming to a client)
// - Calling providers.GetHooks().PreRequest() before entry
// - Persisting the result after return
// - Sending [DONE] or HTTP response after return (via sink.OnDone)
func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResult {
var result LoopResult
maxRounds := lcfg.Budget.maxRounds()
for iteration := 0; iteration < maxRounds; iteration++ {
var iterContent string
var iterReasoning string
var toolCalls []providers.ToolCall
var iterInput, iterOutput, iterCacheCreate, iterCacheRead int
if lcfg.Streaming {
done := runStreamingRound(ctx, lcfg, sink, &result,
&iterContent, &iterReasoning, &toolCalls,
&iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead)
if done {
return result
}
} else {
done := runSyncRound(ctx, lcfg, sink, &result,
&iterContent, &iterReasoning, &toolCalls,
&iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead)
if done {
return result
}
}
// ── Tool execution round ────────────────
if len(toolCalls) == 0 {
// Stream/call ended without tool calls or finish — edge case
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
sink.OnDone()
return result
}
// Budget check: tool calls (pre-execution)
result.ToolCallCount += len(toolCalls)
if lcfg.Budget.MaxToolCalls > 0 && result.ToolCallCount > lcfg.Budget.MaxToolCalls {
result.BudgetExceeded = "tool_calls"
result.Content += iterContent
sink.OnFinish("budget_exceeded")
sink.OnDone()
return result
}
sink.OnToolUse(toolCalls)
// Append assistant message (with tool_calls, possibly with text) to conversation
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
lcfg.Req.Messages = append(lcfg.Req.Messages, assistantMsg)
// Execute all tool calls
for _, tc := range toolCalls {
call := tools.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}
toolResult := executeToolCall(ctx, lcfg, call)
// Notify sink
resultMap := map[string]interface{}{
"tool_call_id": toolResult.ToolCallID,
"name": toolResult.Name,
"content": toolResult.Content,
"is_error": toolResult.IsError,
}
sink.OnToolResult(resultMap)
// Emit workspace.file.changed for live editor updates (v0.21.5)
emitWorkspaceEvent(lcfg, call, toolResult)
// Collect file refs for tool_output auto-save (v0.37.18)
if ref := collectFileRef(lcfg, call, toolResult); ref != nil {
result.FileRefs = append(result.FileRefs, *ref)
}
// Collect for persistence
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
"result": toolResult.Content,
"is_error": toolResult.IsError,
})
// Append tool result to conversation for next iteration
lcfg.Req.Messages = append(lcfg.Req.Messages, providers.Message{
Role: "tool",
Content: toolResult.Content,
ToolCallID: toolResult.ToolCallID,
Name: toolResult.Name,
})
}
result.Content += iterContent
// Budget check: tokens (post-round)
if lcfg.Budget.MaxTokens > 0 && (result.InputTokens+result.OutputTokens) > lcfg.Budget.MaxTokens {
result.BudgetExceeded = "tokens"
sink.OnFinish("budget_exceeded")
sink.OnDone()
return result
}
// Loop: send updated messages back to provider
}
// Hit max iterations
result.BudgetExceeded = "max_rounds"
sink.OnMaxRounds()
sink.OnDone()
return result
}
// ── Streaming Round ────────────────────────────
// runStreamingRound executes one provider StreamCompletion call and
// processes the event stream. Returns true if the loop should return
// (normal finish or error), false if tool execution should follow.
func runStreamingRound(
ctx context.Context,
lcfg LoopConfig,
sink LoopSink,
result *LoopResult,
iterContent, iterReasoning *string,
toolCalls *[]providers.ToolCall,
iterInput, iterOutput, iterCacheCreate, iterCacheRead *int,
) bool {
callStart := time.Now()
ch, err := lcfg.Provider.StreamCompletion(ctx, lcfg.Cfg, *lcfg.Req)
if err != nil {
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err)
result.Error = err
sink.OnError(err)
return true
}
for event := range ch {
// Apply provider-specific post-processing hooks (v0.22.1)
if hooks := providers.GetHooks(lcfg.ProviderType); hooks != nil {
hooks.PostStreamEvent(lcfg.Cfg, &event)
}
if event.Error != nil {
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, event.Error)
result.Error = event.Error
sink.OnError(event.Error)
return true
}
// Reasoning deltas
if event.Reasoning != "" {
*iterReasoning += event.Reasoning
sink.OnReasoning(event.Reasoning)
}
// Content deltas
if event.Delta != "" {
*iterContent += event.Delta
sink.OnDelta(event.Delta)
}
if event.Done {
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, nil)
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
*toolCalls = event.ToolCalls
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
return false // continue to tool execution
}
// Normal completion
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
if *iterReasoning != "" {
result.Content += "<think>" + *iterReasoning + "</think>"
}
result.Content += *iterContent
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
sink.OnFinish(finishReason)
sink.OnDone()
return true
}
}
// Channel closed without Done event — shouldn't happen
return false
}
// ── Sync Round ─────────────────────────────────
// runSyncRound executes one provider ChatCompletion call. Returns true
// if the loop should return, false if tool execution should follow.
func runSyncRound(
ctx context.Context,
lcfg LoopConfig,
sink LoopSink,
result *LoopResult,
iterContent, iterReasoning *string,
toolCalls *[]providers.ToolCall,
iterInput, iterOutput, iterCacheCreate, iterCacheRead *int,
) bool {
callStart := time.Now()
resp, err := lcfg.Provider.ChatCompletion(ctx, lcfg.Cfg, *lcfg.Req)
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err)
if err != nil {
result.Error = err
sink.OnError(err)
return true
}
*iterContent = resp.Content
*iterInput = resp.InputTokens
*iterOutput = resp.OutputTokens
*iterCacheCreate = resp.CacheCreationTokens
*iterCacheRead = resp.CacheReadTokens
result.InputTokens += resp.InputTokens
result.OutputTokens += resp.OutputTokens
result.CacheCreationTokens += resp.CacheCreationTokens
result.CacheReadTokens += resp.CacheReadTokens
if resp.FinishReason == "tool_calls" && len(resp.ToolCalls) > 0 {
*toolCalls = resp.ToolCalls
return false // continue to tool execution
}
// Normal completion — deliver full content as single delta
result.Content += resp.Content
sink.OnDelta(resp.Content)
finishReason := resp.FinishReason
if finishReason == "" {
finishReason = "stop"
}
sink.OnFinish(finishReason)
sink.OnDone()
return true
}
// ── Tool Execution ─────────────────────────────
// executeToolCall dispatches a single tool call: server-side first,
// browser bridge fallback, unknown tool error as last resort.
func executeToolCall(ctx context.Context, lcfg LoopConfig, call tools.ToolCall) tools.ToolResult {
toolStart := time.Now()
// Server-side tool
if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
result := tools.ExecuteCall(ctx, lcfg.ExecCtx, call)
if lcfg.Health != nil {
toolLatency := int(time.Since(toolStart).Milliseconds())
if result.IsError {
lcfg.Health.RecordToolError(call.Name, toolLatency, result.Content)
} else {
lcfg.Health.RecordToolSuccess(call.Name, toolLatency)
}
}
return result
}
// v0.29.2: Starlark extension tool
if pkg, ok := lcfg.ExtTools[call.Name]; ok && lcfg.Runner != nil {
log.Printf("🔧 Executing tool (extension): %s (call %s, pkg %s)", call.Name, call.ID, pkg.ID)
return executeExtensionTool(ctx, lcfg, pkg, call)
}
// Browser bridge (only available when hub is connected)
if lcfg.Hub != nil && lcfg.Hub.IsConnected(lcfg.ExecCtx.UserID) {
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
return executeBrowserTool(lcfg.Hub, lcfg.ExecCtx.UserID, call)
}
// Unknown tool
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
// executeExtensionTool calls a starlark extension's on_tool_call entry point
// and serializes the return value to a JSON string for the tool result.
func executeExtensionTool(ctx context.Context, lcfg LoopConfig, pkg *store.PackageRegistration, call tools.ToolCall) tools.ToolResult {
// Parse JSON arguments into a Starlark dict.
var rawArgs map[string]interface{}
if call.Arguments != "" {
if err := json.Unmarshal([]byte(call.Arguments), &rawArgs); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid tool arguments JSON"}`,
IsError: true,
}
}
}
// Build the call dict passed to on_tool_call(call).
callDict := starlark.NewDict(3)
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(call.Name))
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String(call.ID))
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(rawArgs))
rc := &sandbox.RunContext{
UserID: lcfg.ExecCtx.UserID,
ChannelID: lcfg.ExecCtx.ChannelID,
}
val, output, err := lcfg.Runner.CallEntryPoint(ctx, pkg, "on_tool_call",
starlark.Tuple{callDict}, nil, rc)
if output != "" {
log.Printf(" 🔧 ext tool %s print: %s", pkg.ID, output)
}
if err != nil {
log.Printf("⚠️ ext tool %s on_tool_call error: %v", pkg.ID, err)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: fmt.Sprintf(`{"error":%q}`, err.Error()),
IsError: true,
}
}
// Serialize the Starlark return value to JSON.
content, jsonErr := json.Marshal(starlarkValueToGo(val))
if jsonErr != nil {
content = []byte(fmt.Sprintf(`{"error":%q}`, jsonErr.Error()))
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: string(content),
}
}
// jsonToStarlark recursively converts a decoded-JSON value (map/slice/scalar)
// to its Starlark equivalent.
func jsonToStarlark(v interface{}) starlark.Value {
if v == nil {
return starlark.None
}
switch val := v.(type) {
case map[string]interface{}:
d := starlark.NewDict(len(val))
for k, v := range val {
_ = d.SetKey(starlark.String(k), jsonToStarlark(v))
}
return d
case []interface{}:
elems := make([]starlark.Value, len(val))
for i, v := range val {
elems[i] = jsonToStarlark(v)
}
return starlark.NewList(elems)
case string:
return starlark.String(val)
case float64:
if val == float64(int64(val)) {
return starlark.MakeInt64(int64(val))
}
return starlark.Float(val)
case bool:
return starlark.Bool(val)
default:
return starlark.String(fmt.Sprintf("%v", val))
}
}
// starlarkValueToGo recursively converts a Starlark value to a Go value
// suitable for json.Marshal.
func starlarkValueToGo(v starlark.Value) interface{} {
if v == nil || v == starlark.None {
return nil
}
switch val := v.(type) {
case starlark.String:
return string(val)
case starlark.Int:
i64, ok := val.Int64()
if ok {
return i64
}
return val.String()
case starlark.Float:
return float64(val)
case starlark.Bool:
return bool(val)
case *starlark.Dict:
m := make(map[string]interface{}, val.Len())
for _, item := range val.Items() {
k, ok := starlark.AsString(item[0])
if !ok {
k = item[0].String()
}
m[k] = starlarkValueToGo(item[1])
}
return m
case *starlark.List:
list := make([]interface{}, val.Len())
for i := 0; i < val.Len(); i++ {
list[i] = starlarkValueToGo(val.Index(i))
}
return list
default:
return v.String()
}
}
// emitWorkspaceEvent sends workspace.file.changed when a write tool succeeds.
func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) {
if lcfg.Hub == nil || result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
return
}
if call.Name != "workspace_write" && call.Name != "workspace_patch" {
return
}
var toolArgs struct {
Path string `json:"path"`
}
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
lcfg.Hub.PublishToUser(lcfg.ExecCtx.UserID, events.Event{
Label: "workspace.file.changed",
Payload: events.MustJSON(map[string]string{
"workspace_id": lcfg.ExecCtx.WorkspaceID,
"path": toolArgs.Path,
}),
})
}
}
// collectFileRef returns a FileRef when workspace_write or workspace_patch succeeds.
func collectFileRef(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) *FileRef {
if result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
return nil
}
if call.Name != "workspace_write" && call.Name != "workspace_patch" {
return nil
}
var toolArgs struct {
Path string `json:"path"`
}
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
return &FileRef{
WorkspaceID: lcfg.ExecCtx.WorkspaceID,
Path: toolArgs.Path,
}
}
return nil
}
// ── Sink Implementations ───────────────────────
// sseSink writes SSE events to a gin.Context writer for interactive streaming.
type sseSink struct {
w http.ResponseWriter
flush func()
model string
}
func newSSESink(c *gin.Context, model string) *sseSink {
flusher, _ := c.Writer.(http.Flusher)
return &sseSink{
w: c.Writer,
model: model,
flush: func() {
if flusher != nil {
flusher.Flush()
}
},
}
}
func (s *sseSink) sendData(data string) {
fmt.Fprintf(s.w, "data: %s\n\n", data)
s.flush()
}
func (s *sseSink) sendEvent(event, data string) {
fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, data)
s.flush()
}
func (s *sseSink) OnDelta(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
delta, s.model))
}
func (s *sseSink) OnReasoning(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`,
delta, s.model))
}
func (s *sseSink) OnError(err error) {
s.sendData(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
}
func (s *sseSink) OnToolUse(calls []providers.ToolCall) {
toolCallsJSON, _ := json.Marshal(calls)
s.sendEvent("tool_use", string(toolCallsJSON))
}
func (s *sseSink) OnToolResult(result map[string]interface{}) {
resultJSON, _ := json.Marshal(result)
s.sendEvent("tool_result", string(resultJSON))
}
func (s *sseSink) OnFinish(reason string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
reason, s.model))
}
func (s *sseSink) OnDone() {
s.sendData("[DONE]")
}
func (s *sseSink) OnMaxRounds() {
log.Printf("⚠️ Tool loop hit max iterations for model %s", s.model)
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
escapeJSON("[Tool execution limit reached]"), s.model))
}
// sseModelSink extends sseSink with model_display attribution for
// multi-model streaming. Does NOT send [DONE] — the multiModelStream
// caller sends it after all models finish.
type sseModelSink struct {
sseSink
displayName string
}
func newSSEModelSink(c *gin.Context, model, displayName string) *sseModelSink {
base := newSSESink(c, model)
return &sseModelSink{
sseSink: *base,
displayName: displayName,
}
}
func (s *sseModelSink) OnDelta(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
delta, s.model, s.displayName))
}
func (s *sseModelSink) OnReasoning(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
delta, s.model, s.displayName))
}
func (s *sseModelSink) OnFinish(reason string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`,
reason, s.model, s.displayName))
}
// OnDone is a no-op for multi-model — caller sends [DONE] after all models.
func (s *sseModelSink) OnDone() {}
func (s *sseModelSink) OnMaxRounds() {
log.Printf("⚠️ Multi-model tool loop hit max iterations for model %s", s.displayName)
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`,
escapeJSON("[Tool execution limit reached]"), s.model, s.displayName))
}
// accumSink accumulates silently — used by syncCompletion where the
// caller formats the HTTP response after the loop returns.
type accumSink struct{}
func (accumSink) OnDelta(string) {}
func (accumSink) OnReasoning(string) {}
func (accumSink) OnError(error) {}
func (accumSink) OnToolUse([]providers.ToolCall) {}
func (accumSink) OnToolResult(map[string]interface{}) {}
func (accumSink) OnFinish(string) {}
func (accumSink) OnDone() {}
func (accumSink) OnMaxRounds() {}
// HeadlessSink is used by the task scheduler — accumulates and logs.
// No client connection, no SSE output.
type HeadlessSink struct {
taskID string
}
func NewHeadlessSink(taskID string) *HeadlessSink {
return &HeadlessSink{taskID: taskID}
}
func (s *HeadlessSink) OnDelta(string) {}
func (s *HeadlessSink) OnReasoning(string) {}
func (s *HeadlessSink) OnError(err error) {
log.Printf("[task:%s] Error: %v", s.taskID, err)
}
func (s *HeadlessSink) OnToolUse(calls []providers.ToolCall) {
names := make([]string, len(calls))
for i, tc := range calls {
names[i] = tc.Function.Name
}
log.Printf("[task:%s] Tool calls: %v", s.taskID, names)
}
func (s *HeadlessSink) OnToolResult(result map[string]interface{}) {
name, _ := result["name"].(string)
isErr, _ := result["is_error"].(bool)
if isErr {
log.Printf("[task:%s] Tool %s failed: %v", s.taskID, name, result["content"])
}
}
func (s *HeadlessSink) OnFinish(reason string) {
log.Printf("[task:%s] Finished: %s", s.taskID, reason)
}
func (s *HeadlessSink) OnDone() {}
func (s *HeadlessSink) OnMaxRounds() {
log.Printf("[task:%s] Hit max tool iterations", s.taskID)
}

View File

@@ -1,59 +0,0 @@
package handlers
// This file previously contained all tree traversal logic (path building,
// sibling queries, cursor management). As of v0.15.0, the core logic lives
// in the treepath package; these are package-local aliases so existing
// handler code (messages.go, completion.go) compiles with minimal churn.
//
// New code should import treepath directly.
import (
"switchboard-core/treepath"
)
// ── Type Aliases ────────────────────────────
// Existing handler code references these types by unqualified name.
type PathMessage = treepath.PathMessage
type SiblingInfo = treepath.SiblingInfo
// ── Function Wrappers ───────────────────────
func getActiveLeaf(channelID, userID string) (*string, error) {
return treepath.GetActiveLeaf(channelID, userID)
}
func getActivePath(channelID, userID string) ([]PathMessage, error) {
return treepath.GetActivePath(channelID, userID)
}
func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
return treepath.GetPathToLeaf(channelID, leafID)
}
func getSiblingCount(channelID string, parentID *string) int {
return treepath.GetSiblingCount(channelID, parentID)
}
func getSiblings(messageID string) ([]SiblingInfo, int, error) {
return treepath.GetSiblings(messageID)
}
func findLeafFromMessage(messageID string) (string, error) {
return treepath.FindLeafFromMessage(messageID)
}
func nextSiblingIndex(channelID string, parentID *string) int {
return treepath.NextSiblingIndex(channelID, parentID)
}
func updateCursor(channelID, userID, messageID string) error {
return treepath.UpdateCursor(channelID, userID, messageID)
}
// isSummaryMessage checks if a PathMessage has summary metadata.
// This was previously defined in completion.go; moved here alongside the
// other tree helpers so all callers use the canonical treepath version.
func isSummaryMessage(m *PathMessage) bool {
return treepath.IsSummaryMessage(m)
}

View File

@@ -1,236 +0,0 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// UsageHandler manages usage tracking and pricing endpoints.
type UsageHandler struct {
stores store.Stores
}
// NewUsageHandler creates a usage handler.
func NewUsageHandler(s store.Stores) *UsageHandler {
return &UsageHandler{stores: s}
}
// ── Admin: Aggregated Usage ────────────────
// GET /admin/usage?since=...&until=...&group_by=model|user|day|provider
func (h *UsageHandler) AdminUsage(c *gin.Context) {
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true // Admin views exclude personal BYOK
results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: Per-User Usage ──────────────────
// GET /admin/usage/users/:id
func (h *UsageHandler) AdminUserUsage(c *gin.Context) {
userID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── Admin: Per-Team Usage ──────────────────
// GET /admin/usage/teams/:id
func (h *UsageHandler) AdminTeamUsage(c *gin.Context) {
teamID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── User: Personal Usage ───────────────────
// GET /usage
func (h *UsageHandler) PersonalUsage(c *gin.Context) {
userID := getUserID(c)
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"})
return
}
results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Team Admin: Team Provider Usage ────────
// GET /teams/:teamId/usage
//
// Shows usage against providers owned by this team. Only team admins
// (enforced by RequireTeamAdmin middleware) can see this view.
func (h *UsageHandler) TeamUsage(c *gin.Context) {
teamID := c.Param("teamId")
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"})
return
}
results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: List Pricing ────────────────────
// GET /admin/pricing
func (h *UsageHandler) ListPricing(c *gin.Context) {
entries, err := h.stores.Pricing.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"})
return
}
if entries == nil {
entries = []models.PricingEntry{}
}
c.JSON(http.StatusOK, entries)
}
// ── Admin: Upsert Pricing ──────────────────
// PUT /admin/pricing
func (h *UsageHandler) UpsertPricing(c *gin.Context) {
var req models.PricingEntry
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate provider is admin-managed (global or team), not personal BYOK
pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
if err != nil || pc == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"})
return
}
if pc.Scope == "personal" {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"})
return
}
userID := getUserID(c)
req.Source = "manual"
req.UpdatedBy = &userID
if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing saved"})
}
// ── Admin: Delete Pricing ──────────────────
// DELETE /admin/pricing/:provider/:model
func (h *UsageHandler) DeletePricing(c *gin.Context) {
providerConfigID := c.Param("provider")
modelID := c.Param("model")
if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"})
}
// ── Helpers ────────────────────────────────
func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions {
opts := store.UsageQueryOptions{
GroupBy: c.DefaultQuery("group_by", "model"),
Limit: 100,
}
if since := c.Query("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
opts.Since = &t
}
}
if until := c.Query("until"); until != "" {
if t, err := time.Parse(time.RFC3339, until); err == nil {
opts.Until = &t
}
}
// Convenience shorthand: ?period=7d|30d|90d
if period := c.Query("period"); period != "" && opts.Since == nil {
var dur time.Duration
switch period {
case "7d":
dur = 7 * 24 * time.Hour
case "30d":
dur = 30 * 24 * time.Hour
case "90d":
dur = 90 * 24 * time.Hour
}
if dur > 0 {
t := time.Now().Add(-dur)
opts.Since = &t
}
}
return opts
}

View File

@@ -1,296 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/models"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Workspace Test Harness ────────────────
type workspaceHarness struct {
*testHarness
stores store.Stores
userToken string
userID string
}
func setupWorkspaceHarness(t *testing.T) *workspaceHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
// Workspace handler — nil wfs is fine for CRUD tests that don't touch filesystem
wsH := NewWorkspaceHandler(stores, nil)
protected.GET("/workspaces", wsH.List)
protected.GET("/workspaces/:id", wsH.Get)
// Git credentials — nil vault is fine for List (doesn't decrypt)
gitCredH := NewGitCredentialHandler(stores, nil)
protected.GET("/git-credentials", gitCredH.List)
userID := database.SeedTestUser(t, "wsuser", "wsuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "wsuser@test.com", "user")
return &workspaceHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
userToken: userToken,
userID: userID,
}
}
// seedWorkspace creates a workspace via the store and returns it.
func (h *workspaceHarness) seedWorkspace(name, ownerType, ownerID string) *models.Workspace {
h.t.Helper()
w := &models.Workspace{
Name: name,
OwnerType: ownerType,
OwnerID: ownerID,
Status: models.WorkspaceStatusActive,
RootPath: "workspaces/test-" + name,
}
w.ID = store.NewID()
if err := h.stores.Workspaces.Create(context.Background(), w); err != nil {
h.t.Fatalf("seedWorkspace: %v", err)
}
return w
}
// ── GET /workspaces — envelope ────────────
func TestWorkspaces_List_Empty_Envelope(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Must have "data" key
data, ok := body["data"]
if !ok {
t.Fatal("GET /workspaces must return {\"data\": [...]}, missing \"data\" key")
}
// data must be an array (not null)
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("data should be an array, got %T", data)
}
if len(arr) != 0 {
t.Errorf("expected empty array, got %d items", len(arr))
}
}
func TestWorkspaces_List_WithData_Envelope(t *testing.T) {
h := setupWorkspaceHarness(t)
// Seed a workspace owned by the user
h.seedWorkspace("test-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"].([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(data) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(data))
}
// Verify workspace object shape
ws, ok := data[0].(map[string]interface{})
if !ok {
t.Fatal("workspace entry should be an object")
}
for _, key := range []string{"id", "name", "owner_type", "owner_id", "status", "created_at", "updated_at"} {
if _, exists := ws[key]; !exists {
t.Errorf("missing field %q in workspace object", key)
}
}
if ws["name"] != "test-ws" {
t.Errorf("name: got %v, want test-ws", ws["name"])
}
if ws["owner_type"] != "user" {
t.Errorf("owner_type: got %v, want user", ws["owner_type"])
}
}
func TestWorkspaces_List_DoesNotExposeRootPath(t *testing.T) {
h := setupWorkspaceHarness(t)
h.seedWorkspace("secret-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data := body["data"].([]interface{})
ws := data[0].(map[string]interface{})
if _, exists := ws["root_path"]; exists {
t.Error("root_path must never be exposed in API responses")
}
}
func TestWorkspaces_List_IsolatedByUser(t *testing.T) {
h := setupWorkspaceHarness(t)
// Seed workspace for another user
otherID := database.SeedTestUser(t, "other", "other@test.com")
h.seedWorkspace("other-ws", models.WorkspaceOwnerUser, otherID)
// Also seed one for our user
h.seedWorkspace("my-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data := body["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 workspace (own only), got %d", len(data))
}
ws := data[0].(map[string]interface{})
if ws["name"] != "my-ws" {
t.Errorf("should only see own workspace, got %v", ws["name"])
}
}
// ── GET /workspaces/:id — single object ───
func TestWorkspaces_Get_Shape(t *testing.T) {
h := setupWorkspaceHarness(t)
w := h.seedWorkspace("detail-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Single object — no "data" wrapper
if _, exists := body["data"]; exists {
t.Error("GET /:id should return object directly, not wrapped in 'data'")
}
if body["id"] != w.ID {
t.Errorf("id: got %v, want %s", body["id"], w.ID)
}
if body["name"] != "detail-ws" {
t.Errorf("name: got %v, want detail-ws", body["name"])
}
}
func TestWorkspaces_Get_NotFound(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/workspaces/00000000-0000-0000-0000-000000000000", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.Code)
}
}
func TestWorkspaces_Get_ForbiddenForOtherUser(t *testing.T) {
h := setupWorkspaceHarness(t)
otherID := database.SeedTestUser(t, "stranger", "stranger@test.com")
w := h.seedWorkspace("private-ws", models.WorkspaceOwnerUser, otherID)
resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil)
if resp.Code != http.StatusForbidden {
t.Fatalf("expected 403 for other user's workspace, got %d", resp.Code)
}
}
// ── GET /git-credentials — envelope ───────
func TestGitCredentials_List_Empty_Envelope(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Must have "data" key
data, ok := body["data"]
if !ok {
t.Fatal("GET /git-credentials must return {\"data\": [...]}, missing \"data\" key")
}
// data must be an array (not null)
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("data should be an array, got %T", data)
}
if len(arr) != 0 {
t.Errorf("expected empty array, got %d items", len(arr))
}
}
// ── Auth ──────────────────────────────────
func TestWorkspaces_RequiresAuth(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/workspaces", "", nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.Code)
}
}
func TestGitCredentials_RequiresAuth(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", "", nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.Code)
}
}

View File

@@ -1,589 +0,0 @@
package handlers
import (
"context"
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workspace"
)
// ── Request Types ───────────────────────────
type createWorkspaceRequest struct {
Name string `json:"name" binding:"required,max=200"`
OwnerType string `json:"owner_type" binding:"required,oneof=user project channel team"`
OwnerID string `json:"owner_id" binding:"required"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
}
type updateWorkspaceRequest = models.WorkspacePatch
// ── Handler ─────────────────────────────────
// WorkspaceHandler handles workspace CRUD, file operations, and archive management.
type WorkspaceHandler struct {
stores store.Stores
wfs *workspace.FS
}
// NewWorkspaceHandler creates a new workspace handler.
func NewWorkspaceHandler(s store.Stores, wfs *workspace.FS) *WorkspaceHandler {
return &WorkspaceHandler{stores: s, wfs: wfs}
}
// ── Workspace CRUD ──────────────────────────
func (h *WorkspaceHandler) Create(c *gin.Context) {
var req createWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Authorization: verify the caller owns or can access the owner entity
if !h.canAccessOwner(c, req.OwnerType, req.OwnerID) {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
w := &models.Workspace{
OwnerType: req.OwnerType,
OwnerID: req.OwnerID,
Name: req.Name,
MaxBytes: req.MaxBytes,
Status: models.WorkspaceStatusActive,
}
// Pre-generate ID so root_path can be set before DB insert.
// Works for both Postgres (overrides gen_random_uuid()) and SQLite (store.NewID()).
w.ID = store.NewID()
w.RootPath = "workspaces/" + w.ID
if err := h.stores.Workspaces.Create(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"})
log.Printf("workspace create: %v", err)
return
}
// Create directory on disk
if err := h.wfs.CreateDir(w); err != nil {
log.Printf("workspace mkdir: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace directory"})
return
}
c.JSON(http.StatusCreated, w)
}
// GetDefault returns the current user's personal workspace, creating it on first access.
// The personal workspace is the first user-owned workspace (by created_at) or a new
// "My Files" workspace if none exists. Idempotent — safe to call on every page load.
func (h *WorkspaceHandler) GetDefault(c *gin.Context) {
userID := getUserID(c)
ctx := c.Request.Context()
w, err := h.stores.Workspaces.GetByOwner(ctx, models.WorkspaceOwnerUser, userID)
if err == nil {
// Enrich with stats
stats, _ := h.stores.Workspaces.GetStats(ctx, w.ID)
if stats != nil {
w.FileCount = stats.FileCount
w.TotalBytes = stats.TotalBytes
}
c.JSON(http.StatusOK, w)
return
}
if err != sql.ErrNoRows {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch workspace"})
log.Printf("workspace getDefault: %v", err)
return
}
// Auto-create personal workspace
w = &models.Workspace{
OwnerType: models.WorkspaceOwnerUser,
OwnerID: userID,
Name: "My Files",
Status: models.WorkspaceStatusActive,
}
w.ID = store.NewID()
w.RootPath = "workspaces/" + w.ID
if err := h.stores.Workspaces.Create(ctx, w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create default workspace"})
log.Printf("workspace getDefault create: %v", err)
return
}
if err := h.wfs.CreateDir(w); err != nil {
log.Printf("workspace getDefault mkdir: %v", err)
// Workspace record exists but directory failed — still return the workspace.
// Directory will be created on first file upload.
}
c.JSON(http.StatusOK, w)
}
func (h *WorkspaceHandler) Get(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Enrich with stats
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
if err == nil {
w.FileCount = stats.FileCount
w.TotalBytes = stats.TotalBytes
}
c.JSON(http.StatusOK, w)
}
// List returns all workspaces accessible to the current user.
// Includes user-owned workspaces and those owned by the user's teams.
func (h *WorkspaceHandler) List(c *gin.Context) {
userID := getUserID(c)
ctx := c.Request.Context()
// Fetch user-owned workspaces
userWs, err := h.stores.Workspaces.ListByOwner(ctx, "user", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
all := make([]models.Workspace, 0, len(userWs))
all = append(all, userWs...)
// Also include team-owned workspaces
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
for _, tid := range teamIDs {
teamWs, err := h.stores.Workspaces.ListByOwner(ctx, "team", tid)
if err == nil {
all = append(all, teamWs...)
}
}
c.JSON(http.StatusOK, gin.H{"data": all})
}
func (h *WorkspaceHandler) Update(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req updateWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Workspaces.Update(c.Request.Context(), w.ID, req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workspace"})
return
}
// Re-fetch to return the updated object
updated, err := h.stores.Workspaces.GetByID(c.Request.Context(), w.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload workspace"})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *WorkspaceHandler) Delete(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Mark as deleting
status := models.WorkspaceStatusDeleting
h.stores.Workspaces.Update(c.Request.Context(), w.ID, models.WorkspacePatch{Status: &status})
// Async cleanup (use background context — request ctx will be cancelled)
go func() {
ctx := context.Background()
if err := h.wfs.Destroy(ctx, w); err != nil {
log.Printf("workspace destroy %s: %v", w.ID, err)
}
h.stores.Workspaces.Delete(ctx, w.ID)
log.Printf("workspace deleted: %s", w.ID)
}()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── File Operations ─────────────────────────
func (h *WorkspaceHandler) ListFiles(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
recursive := c.Query("recursive") == "true"
files, err := h.wfs.ListDir(c.Request.Context(), w, path, recursive)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.WorkspaceFile{}
}
c.JSON(http.StatusOK, gin.H{"data": files})
}
func (h *WorkspaceHandler) ReadFile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
rc, size, err := h.wfs.ReadFile(c.Request.Context(), w, path)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
defer rc.Close()
// Detect content type for response header
f, _ := h.wfs.Stat(c.Request.Context(), w, path)
contentType := "application/octet-stream"
if f != nil && f.ContentType != "" {
contentType = f.ContentType
}
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
}
func (h *WorkspaceHandler) WriteFile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
// Quota check
stats, _ := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
quota := workspace.DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if stats != nil && stats.TotalBytes+c.Request.ContentLength > quota {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
})
return
}
if err := h.wfs.WriteFile(c.Request.Context(), w, path, c.Request.Body, c.Request.ContentLength); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "path": path})
}
func (h *WorkspaceHandler) DeleteFileHandler(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
recursive := c.Query("recursive") == "true"
if err := h.wfs.DeleteFile(c.Request.Context(), w, path, recursive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *WorkspaceHandler) Mkdir(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
if err := h.wfs.Mkdir(c.Request.Context(), w, path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": path})
}
// ── Archive Operations ──────────────────────
func (h *WorkspaceHandler) UploadArchive(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Accept multipart file upload
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
return
}
defer file.Close()
// Detect format from filename
format := detectArchiveFormat(header.Filename)
if format == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
return
}
// Save to temp file (archive extraction needs seekable file for zip)
tmp, err := os.CreateTemp("", "ws-upload-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
count, err := h.wfs.ExtractArchive(c.Request.Context(), w, tmpName, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"files_extracted": count,
})
}
func (h *WorkspaceHandler) DownloadArchive(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
format := c.DefaultQuery("format", "zip")
if format != "zip" && format != "tar.gz" && format != "tgz" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
return
}
archivePath, err := h.wfs.CreateArchive(c.Request.Context(), w, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.Remove(archivePath)
ext := "zip"
contentType := "application/zip"
if format == "tar.gz" || format == "tgz" {
ext = "tar.gz"
contentType = "application/gzip"
}
filename := fmt.Sprintf("%s.%s", w.Name, ext)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.File(archivePath)
_ = contentType // c.File sets the content type from the file
}
// ── Reconcile + Stats ───────────────────────
func (h *WorkspaceHandler) Reconcile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
added, removed, updated, err := h.wfs.Reconcile(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"added": added,
"removed": removed,
"updated": updated,
})
}
func (h *WorkspaceHandler) Stats(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"})
return
}
c.JSON(http.StatusOK, stats)
}
// IndexStatus returns indexing progress for all files in the workspace (v0.21.2).
func (h *WorkspaceHandler) IndexStatus(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
ctx := c.Request.Context()
files, err := h.stores.Workspaces.ListFiles(ctx, w.ID, "", true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
// Aggregate counts by status
counts := map[string]int{
"pending": 0, "indexing": 0, "ready": 0,
"error": 0, "skipped": 0,
}
totalChunks := 0
for _, f := range files {
if f.IsDirectory {
continue
}
status := f.IndexStatus
if status == "" {
status = "pending"
}
counts[status]++
totalChunks += f.ChunkCount
}
c.JSON(http.StatusOK, gin.H{
"workspace_id": w.ID,
"indexing_enabled": w.IndexingEnabled,
"status_counts": counts,
"total_chunks": totalChunks,
})
}
// ── Authorization Helpers ───────────────────
// loadAndAuthorize loads a workspace by :id param and checks user access.
func (h *WorkspaceHandler) loadAndAuthorize(c *gin.Context) (*models.Workspace, bool) {
wsID := c.Param("id")
ctx := c.Request.Context()
w, err := h.stores.Workspaces.GetByID(ctx, wsID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"})
}
return nil, false
}
if !h.canAccessOwner(c, w.OwnerType, w.OwnerID) {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return nil, false
}
return w, true
}
// canAccessOwner checks if the current user can access the workspace's owner entity.
func (h *WorkspaceHandler) canAccessOwner(c *gin.Context, ownerType, ownerID string) bool {
userID := getUserID(c)
ctx := c.Request.Context()
switch ownerType {
case models.WorkspaceOwnerUser:
return ownerID == userID
case models.WorkspaceOwnerChannel:
// User must own the channel
ch, err := h.stores.Channels.GetByID(ctx, ownerID)
if err != nil {
return false
}
return ch.UserID == userID
case models.WorkspaceOwnerProject:
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
ok, _ := h.stores.Projects.UserCanAccess(ctx, userID, ownerID, teamIDs)
return ok
case models.WorkspaceOwnerTeam:
// User must be a member of the team
_, err := h.stores.Teams.GetMember(ctx, ownerID, userID)
return err == nil
default:
return false
}
}
// ── Helpers ─────────────────────────────────
// detectArchiveFormat returns "zip" or "tar.gz" based on filename, or "" if unsupported.
func detectArchiveFormat(filename string) string {
lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".zip") {
return "zip"
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return "tar.gz"
}
return ""
}

View File

@@ -1,244 +0,0 @@
package knowledge
import (
"strings"
"unicode/utf8"
)
// ── Configuration ────────────────────────────
// ChunkConfig controls how text is split into chunks.
type ChunkConfig struct {
ChunkSize int // target chars per chunk (default 1000)
ChunkOverlap int // overlap chars between adjacent chunks (default 200)
Separators []string // split hierarchy, tried in order
}
// DefaultChunkConfig returns sensible defaults for general documents.
// ~250 tokens per chunk at ~4 chars/token.
func DefaultChunkConfig() ChunkConfig {
return ChunkConfig{
ChunkSize: 1000,
ChunkOverlap: 200,
Separators: []string{"\n\n", "\n", ". ", " "},
}
}
// ── Output ───────────────────────────────────
// Chunk is a single text segment with position metadata.
type Chunk struct {
Content string // chunk text
Index int // ordinal within document (0-based)
TokenCount int // estimated token count (~chars/4)
Metadata map[string]any // extensible: page, heading, etc.
}
// ── Splitter ─────────────────────────────────
// SplitText splits text into overlapping chunks using recursive
// character splitting. It tries separators in order, splitting on
// the first one that produces segments smaller than ChunkSize.
// Segments that are still too large are recursively split with the
// next separator.
func SplitText(text string, cfg ChunkConfig) []Chunk {
if cfg.ChunkSize <= 0 {
cfg.ChunkSize = 1000
}
if cfg.ChunkOverlap < 0 {
cfg.ChunkOverlap = 0
}
if cfg.ChunkOverlap >= cfg.ChunkSize {
cfg.ChunkOverlap = cfg.ChunkSize / 5
}
if len(cfg.Separators) == 0 {
cfg.Separators = DefaultChunkConfig().Separators
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
// If text fits in one chunk, return it directly.
if utf8.RuneCountInString(text) <= cfg.ChunkSize {
return []Chunk{{
Content: text,
Index: 0,
TokenCount: estimateTokens(text),
Metadata: map[string]any{},
}}
}
// Recursively split, then merge into overlapping windows.
segments := recursiveSplit(text, cfg.Separators, cfg.ChunkSize)
return mergeSegments(segments, cfg.ChunkSize, cfg.ChunkOverlap)
}
// recursiveSplit tries each separator in order. For the first separator
// that actually splits the text, it checks if each piece fits within
// chunkSize. Pieces that are still too large recurse with the remaining
// separators. Pieces that fit are kept as-is.
func recursiveSplit(text string, separators []string, chunkSize int) []string {
if utf8.RuneCountInString(text) <= chunkSize {
return []string{text}
}
if len(separators) == 0 {
// No separators left — hard split by character count.
return hardSplit(text, chunkSize)
}
sep := separators[0]
rest := separators[1:]
parts := splitKeepSep(text, sep)
if len(parts) <= 1 {
// This separator didn't split anything — try next.
return recursiveSplit(text, rest, chunkSize)
}
var result []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if utf8.RuneCountInString(part) <= chunkSize {
result = append(result, part)
} else {
// Still too large — recurse with remaining separators.
result = append(result, recursiveSplit(part, rest, chunkSize)...)
}
}
return result
}
// splitKeepSep splits text on sep. Unlike strings.Split, the separator
// is kept at the end of each segment (except the last) to preserve
// context like sentence-ending periods.
func splitKeepSep(text, sep string) []string {
parts := strings.Split(text, sep)
if len(parts) <= 1 {
return parts
}
result := make([]string, 0, len(parts))
for i, part := range parts {
if i < len(parts)-1 {
result = append(result, part+sep)
} else if part != "" {
result = append(result, part)
}
}
return result
}
// hardSplit cuts text into pieces of at most chunkSize runes.
// Last resort when no separator works.
func hardSplit(text string, chunkSize int) []string {
runes := []rune(text)
var result []string
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
result = append(result, string(runes[i:end]))
}
return result
}
// mergeSegments combines small segments into chunks up to chunkSize,
// then applies overlap between adjacent chunks.
func mergeSegments(segments []string, chunkSize, overlap int) []Chunk {
if len(segments) == 0 {
return nil
}
// First pass: merge small adjacent segments into windows up to chunkSize.
var merged []string
var current strings.Builder
for _, seg := range segments {
segLen := utf8.RuneCountInString(seg)
curLen := utf8.RuneCountInString(current.String())
if curLen > 0 && curLen+segLen > chunkSize {
// Flush current buffer.
merged = append(merged, strings.TrimSpace(current.String()))
current.Reset()
}
if current.Len() > 0 {
current.WriteString(" ")
}
current.WriteString(seg)
}
if current.Len() > 0 {
merged = append(merged, strings.TrimSpace(current.String()))
}
if overlap <= 0 || len(merged) <= 1 {
// No overlap needed — convert directly to Chunks.
chunks := make([]Chunk, len(merged))
for i, text := range merged {
chunks[i] = Chunk{
Content: text,
Index: i,
TokenCount: estimateTokens(text),
Metadata: map[string]any{},
}
}
return chunks
}
// Second pass: create overlapping chunks.
// Each chunk includes up to `overlap` chars from the end of the
// previous chunk as prefix context.
chunks := make([]Chunk, 0, len(merged))
for i, text := range merged {
if i > 0 {
prev := merged[i-1]
suffix := overlapSuffix(prev, overlap)
if suffix != "" {
text = suffix + " " + text
}
}
chunks = append(chunks, Chunk{
Content: text,
Index: i,
TokenCount: estimateTokens(text),
Metadata: map[string]any{},
})
}
return chunks
}
// overlapSuffix returns the last `n` characters of s, breaking at a
// word boundary to avoid splitting mid-word.
func overlapSuffix(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
start := len(runes) - n
suffix := string(runes[start:])
// Try to break at a space to avoid mid-word splits.
if idx := strings.Index(suffix, " "); idx >= 0 && idx < len(suffix)/2 {
suffix = suffix[idx+1:]
}
return suffix
}
// estimateTokens gives a rough token count. Most tokenizers average
// ~4 characters per token for English text.
func estimateTokens(s string) int {
n := utf8.RuneCountInString(s)
tokens := n / 4
if tokens == 0 && n > 0 {
tokens = 1
}
return tokens
}

View File

@@ -1,227 +0,0 @@
package knowledge
import (
"strings"
"testing"
"unicode/utf8"
)
func TestSplitText_EmptyInput(t *testing.T) {
chunks := SplitText("", DefaultChunkConfig())
if chunks != nil {
t.Errorf("expected nil for empty input, got %d chunks", len(chunks))
}
}
func TestSplitText_WhitespaceOnly(t *testing.T) {
chunks := SplitText(" \n\n ", DefaultChunkConfig())
if chunks != nil {
t.Errorf("expected nil for whitespace input, got %d chunks", len(chunks))
}
}
func TestSplitText_SmallText(t *testing.T) {
text := "Hello, world."
chunks := SplitText(text, DefaultChunkConfig())
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(chunks))
}
if chunks[0].Content != text {
t.Errorf("content mismatch: %q", chunks[0].Content)
}
if chunks[0].Index != 0 {
t.Errorf("expected index 0, got %d", chunks[0].Index)
}
if chunks[0].TokenCount <= 0 {
t.Error("expected positive token count")
}
}
func TestSplitText_ParagraphSplit(t *testing.T) {
// Two paragraphs, each under chunk size, but together over.
para := strings.Repeat("word ", 60) // ~300 chars each
text := para + "\n\n" + para
cfg := ChunkConfig{ChunkSize: 400, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}}
chunks := SplitText(text, cfg)
if len(chunks) < 2 {
t.Fatalf("expected at least 2 chunks from paragraph split, got %d", len(chunks))
}
for i, c := range chunks {
if utf8.RuneCountInString(c.Content) == 0 {
t.Errorf("chunk %d is empty", i)
}
}
}
func TestSplitText_OverlapPresent(t *testing.T) {
// Build text that will split into multiple chunks.
sentences := make([]string, 20)
for i := range sentences {
sentences[i] = "This is sentence number " + strings.Repeat("x", 40) + ". "
}
text := strings.Join(sentences, "")
cfg := ChunkConfig{ChunkSize: 200, ChunkOverlap: 50, Separators: []string{". ", " "}}
chunks := SplitText(text, cfg)
if len(chunks) < 3 {
t.Fatalf("expected at least 3 chunks, got %d", len(chunks))
}
// Verify chunks have sequential indexes.
for i, c := range chunks {
if c.Index != i {
t.Errorf("chunk %d has index %d", i, c.Index)
}
}
// With overlap > 0 and multiple chunks, later chunks should share
// some content with the previous chunk's tail.
// Just verify the overlap logic ran (chunks 1+ are longer or contain
// content from the previous chunk's ending).
if len(chunks) >= 2 {
// Chunk 1 should contain some overlap from chunk 0's tail.
// We can't check exact content easily, but the chunk should be
// non-trivially sized.
if len(chunks[1].Content) < 50 {
t.Errorf("chunk 1 seems too short for overlap: %d chars", len(chunks[1].Content))
}
}
}
func TestSplitText_ZeroOverlap(t *testing.T) {
text := strings.Repeat("A", 500) + "\n\n" + strings.Repeat("B", 500)
cfg := ChunkConfig{ChunkSize: 400, ChunkOverlap: 0, Separators: []string{"\n\n"}}
chunks := SplitText(text, cfg)
if len(chunks) < 2 {
t.Fatalf("expected at least 2 chunks, got %d", len(chunks))
}
// Without overlap, chunks should not share content.
for i, c := range chunks {
if c.Index != i {
t.Errorf("chunk %d index = %d", i, c.Index)
}
}
}
func TestSplitText_HardSplitFallback(t *testing.T) {
// No separators at all in the text — forces hard character split.
text := strings.Repeat("x", 500)
cfg := ChunkConfig{ChunkSize: 200, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}}
chunks := SplitText(text, cfg)
if len(chunks) < 2 {
t.Fatalf("expected multiple chunks from hard split, got %d", len(chunks))
}
// All chunks should be at most chunkSize.
for i, c := range chunks {
if utf8.RuneCountInString(c.Content) > cfg.ChunkSize {
t.Errorf("chunk %d exceeds chunk size: %d > %d",
i, utf8.RuneCountInString(c.Content), cfg.ChunkSize)
}
}
}
func TestSplitText_TokenEstimate(t *testing.T) {
text := strings.Repeat("word ", 100) // 500 chars
chunks := SplitText(text, ChunkConfig{ChunkSize: 2000, ChunkOverlap: 0})
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(chunks))
}
// ~500 chars / 4 = ~125 tokens
if chunks[0].TokenCount < 100 || chunks[0].TokenCount > 150 {
t.Errorf("expected ~125 tokens, got %d", chunks[0].TokenCount)
}
}
func TestSplitText_InvalidConfig(t *testing.T) {
text := "Hello world. This is a test."
// ChunkSize 0 should use default.
chunks := SplitText(text, ChunkConfig{ChunkSize: 0})
if len(chunks) != 1 {
t.Errorf("expected 1 chunk with zero chunk size (uses default), got %d", len(chunks))
}
// Overlap >= ChunkSize should be clamped.
chunks = SplitText(strings.Repeat("word ", 200), ChunkConfig{
ChunkSize: 100,
ChunkOverlap: 100,
})
if len(chunks) == 0 {
t.Error("expected chunks with overlap == chunk_size (should clamp)")
}
}
func TestSplitText_SentenceSplit(t *testing.T) {
text := "First sentence. Second sentence. Third sentence. Fourth sentence. Fifth sentence."
cfg := ChunkConfig{ChunkSize: 40, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}}
chunks := SplitText(text, cfg)
if len(chunks) < 2 {
t.Fatalf("expected multiple sentence-split chunks, got %d", len(chunks))
}
// Each chunk should be within bounds.
for i, c := range chunks {
if utf8.RuneCountInString(c.Content) == 0 {
t.Errorf("chunk %d is empty", i)
}
}
}
func TestSplitText_MetadataInitialized(t *testing.T) {
chunks := SplitText("Some text here.", DefaultChunkConfig())
if len(chunks) != 1 {
t.Fatal("expected 1 chunk")
}
if chunks[0].Metadata == nil {
t.Error("metadata should be initialized, got nil")
}
}
func TestEstimateTokens(t *testing.T) {
cases := []struct {
input string
minTok int
maxTok int
}{
{"", 0, 0},
{"hi", 1, 1},
{"hello world", 2, 4},
{strings.Repeat("a", 100), 20, 30},
}
for _, tc := range cases {
got := estimateTokens(tc.input)
if got < tc.minTok || got > tc.maxTok {
t.Errorf("estimateTokens(%q): got %d, want [%d, %d]", tc.input, got, tc.minTok, tc.maxTok)
}
}
}
func TestOverlapSuffix(t *testing.T) {
s := "the quick brown fox jumps over the lazy dog"
// Should return last ~20 chars, breaking at word boundary.
suffix := overlapSuffix(s, 20)
if len(suffix) == 0 {
t.Error("expected non-empty suffix")
}
if len(suffix) > 25 { // some slack for word boundary adjustment
t.Errorf("suffix too long: %d chars: %q", len(suffix), suffix)
}
// Short string: return whole thing.
short := "hello"
if got := overlapSuffix(short, 100); got != short {
t.Errorf("expected %q, got %q", short, got)
}
}

View File

@@ -1,184 +0,0 @@
package knowledge
import (
"context"
"fmt"
"log"
"switchboard-core/models"
"switchboard-core/roles"
"switchboard-core/store"
)
// ── Configuration ────────────────────────────
const (
// DefaultBatchSize is the max chunks per embedding API call.
// Most providers handle 100 inputs per request comfortably.
DefaultBatchSize = 100
// MaxVectorDim is the column width in pgvector (vector(3072)).
// Vectors shorter than this are zero-padded on storage.
MaxVectorDim = 3072
)
// ── Embedder ─────────────────────────────────
// Embedder generates vector embeddings for text chunks using the
// embedding role resolver. It handles batching and dimension
// normalization (zero-padding to MaxVectorDim).
type Embedder struct {
resolver *roles.Resolver
stores store.Stores // optional — for usage tracking
batchSize int
}
// NewEmbedder creates an embedder that dispatches through the role resolver.
func NewEmbedder(resolver *roles.Resolver) *Embedder {
return &Embedder{
resolver: resolver,
batchSize: DefaultBatchSize,
}
}
// WithStores attaches stores for usage tracking. Returns self for chaining.
func (e *Embedder) WithStores(s store.Stores) *Embedder {
e.stores = s
return e
}
// EmbedResult holds the output of a batch embedding operation.
type EmbedResult struct {
Vectors [][]float64 // one per input chunk, zero-padded to MaxVectorDim
Model string // model that produced the embeddings
Dimensions int // native dimension before padding
InputTokens int // total tokens consumed across all batches
ConfigID string // provider config that was used
ProviderScope string // "personal", "team", or "global"
}
// EmbedChunks generates embeddings for a slice of text chunks.
// Chunks are batched in groups of batchSize to avoid provider limits.
// Uses the embedding role resolution chain: personal → team → global.
//
// Returns vectors in the same order as input chunks.
func (e *Embedder) EmbedChunks(ctx context.Context, userID string, teamID *string, texts []string) (*EmbedResult, error) {
if len(texts) == 0 {
return &EmbedResult{Vectors: [][]float64{}}, nil
}
// Verify embedding role is configured before starting
cfg, err := e.resolver.GetConfig(ctx, roles.RoleEmbedding, userID, teamID)
if err != nil {
return nil, fmt.Errorf("embedding role not configured: %w", err)
}
if cfg.Primary == nil {
return nil, fmt.Errorf("embedding role has no primary binding")
}
allVectors := make([][]float64, 0, len(texts))
var model string
var nativeDim int
var configID, provScope string
totalTokens := 0
for i := 0; i < len(texts); i += e.batchSize {
end := i + e.batchSize
if end > len(texts) {
end = len(texts)
}
batch := texts[i:end]
log.Printf(" embed batch %d/%d (%d chunks)",
(i/e.batchSize)+1, (len(texts)+e.batchSize-1)/e.batchSize, len(batch))
result, err := e.resolver.Embed(ctx, roles.RoleEmbedding, userID, teamID, batch)
if err != nil {
return nil, fmt.Errorf("embed batch starting at chunk %d: %w", i, err)
}
if len(result.Embeddings) != len(batch) {
return nil, fmt.Errorf("embed batch %d: expected %d vectors, got %d",
i/e.batchSize, len(batch), len(result.Embeddings))
}
// Capture model info from first batch
if model == "" {
model = result.Model
configID = result.ConfigID
provScope = result.ProviderScope
if len(result.Embeddings) > 0 {
nativeDim = len(result.Embeddings[0])
}
}
totalTokens += result.InputTokens
allVectors = append(allVectors, result.Embeddings...)
}
// Zero-pad to MaxVectorDim for uniform storage
for i, vec := range allVectors {
allVectors[i] = padVector(vec, MaxVectorDim)
}
return &EmbedResult{
Vectors: allVectors,
Model: model,
Dimensions: nativeDim,
InputTokens: totalTokens,
ConfigID: configID,
ProviderScope: provScope,
}, nil
}
// LogUsage records an embedding usage entry. Silently no-ops if stores
// were not attached via WithStores or if there are no tokens to log.
func (e *Embedder) LogUsage(ctx context.Context, userID string, channelID *string, result *EmbedResult) {
if e.stores.Usage == nil || result == nil || result.InputTokens == 0 {
return
}
role := "embedding"
entry := &models.UsageEntry{
ChannelID: channelID,
UserID: userID,
ProviderConfigID: strPtr(result.ConfigID),
ProviderScope: result.ProviderScope,
ModelID: result.Model,
Role: &role,
PromptTokens: result.InputTokens,
CompletionTokens: 0,
}
if err := e.stores.Usage.Log(ctx, entry); err != nil {
log.Printf("⚠ Failed to log embedding usage: %v", err)
}
}
// IsConfigured returns true if the embedding role has at least a primary binding.
func (e *Embedder) IsConfigured(ctx context.Context) bool {
return e.resolver.IsConfigured(ctx, roles.RoleEmbedding)
}
// ── Helpers ──────────────────────────────────
// padVector zero-pads a vector to the target dimension.
// If the vector is already the target size or larger, it's truncated.
func padVector(vec []float64, targetDim int) []float64 {
if len(vec) == targetDim {
return vec
}
if len(vec) > targetDim {
return vec[:targetDim]
}
padded := make([]float64, targetDim)
copy(padded, vec)
return padded
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -1,268 +0,0 @@
package knowledge
import (
"context"
"fmt"
"io"
"log"
"strings"
"sync"
"time"
"switchboard-core/models"
"switchboard-core/notifications"
"switchboard-core/storage"
"switchboard-core/store"
)
// ── Configuration ────────────────────────────
const (
// DefaultConcurrency limits parallel ingestion goroutines.
DefaultConcurrency = 3
// MaxTextLength is the upper bound for extracted text (10 MB).
// Documents larger than this are truncated with a warning.
MaxTextLength = 10 * 1024 * 1024
// contextTimeout for the entire ingestion of one document.
ingestTimeout = 10 * time.Minute
)
// textMIMETypes are content types we can extract text from directly.
var textMIMETypes = map[string]bool{
"text/plain": true,
"text/markdown": true,
"text/csv": true,
"text/html": true,
}
// ── Ingester ─────────────────────────────────
// Ingester manages the document ingestion pipeline.
// It runs chunk + embed as background goroutines with a concurrency
// semaphore to avoid overwhelming the embedding provider.
type Ingester struct {
stores store.Stores
embedder *Embedder
objStore storage.ObjectStore
sem chan struct{}
wg sync.WaitGroup
}
// NewIngester creates an ingestion pipeline.
func NewIngester(stores store.Stores, embedder *Embedder, objStore storage.ObjectStore, concurrency int) *Ingester {
if concurrency <= 0 {
concurrency = DefaultConcurrency
}
return &Ingester{
stores: stores,
embedder: embedder,
objStore: objStore,
sem: make(chan struct{}, concurrency),
}
}
// IngestDocument starts async ingestion for a document.
// The document must already exist in kb_documents with status "pending"
// and the file must be stored at doc.StorageKey in the object store.
//
// The goroutine progresses through: pending → extracting → chunking →
// embedding → ready (or → error on failure).
func (ing *Ingester) IngestDocument(kb *models.KnowledgeBase, doc *models.KBDocument, userID string, teamID *string) {
ing.wg.Add(1)
go func() {
defer ing.wg.Done()
// Acquire semaphore slot
ing.sem <- struct{}{}
defer func() { <-ing.sem }()
ctx, cancel := context.WithTimeout(context.Background(), ingestTimeout)
defer cancel()
if err := ing.ingest(ctx, kb, doc, userID, teamID); err != nil {
errMsg := err.Error()
log.Printf("❌ ingest doc %s (%s): %v", doc.ID, doc.Filename, err)
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "error", &errMsg)
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"})
// Notify document owner of ingestion failure (v0.20.0)
if svc := notifications.Default(); svc != nil {
notifications.NotifyKBError(svc, userID, kb.ID, kb.Name, errMsg)
}
}
}()
}
// Wait blocks until all in-flight ingestion goroutines complete.
// Call during graceful shutdown.
func (ing *Ingester) Wait() {
ing.wg.Wait()
}
// ── Internal Pipeline ────────────────────────
func (ing *Ingester) ingest(ctx context.Context, kb *models.KnowledgeBase, doc *models.KBDocument, userID string, teamID *string) error {
start := time.Now()
log.Printf("▶ ingest %s (%s, %d bytes)", doc.Filename, doc.ContentType, doc.SizeBytes)
// Mark KB as processing
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "processing"})
// ── Step 1: Extract text ────────────────
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "extracting", nil)
text, err := ing.extractText(ctx, doc)
if err != nil {
return fmt.Errorf("extract: %w", err)
}
if strings.TrimSpace(text) == "" {
return fmt.Errorf("extract: no text content found in %s", doc.Filename)
}
// Truncate very large documents
if len(text) > MaxTextLength {
log.Printf(" ⚠ truncating %s from %d to %d chars", doc.Filename, len(text), MaxTextLength)
text = text[:MaxTextLength]
}
// Store extracted text for potential re-chunking later
if err := ing.stores.KnowledgeBases.UpdateDocumentText(ctx, doc.ID, text, 0); err != nil {
log.Printf(" ⚠ failed to store extracted text for %s: %v", doc.ID, err)
}
// ── Step 2: Chunk ───────────────────────
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "chunking", nil)
cfg := DefaultChunkConfig()
chunks := SplitText(text, cfg)
if len(chunks) == 0 {
return fmt.Errorf("chunk: produced zero chunks from %s", doc.Filename)
}
log.Printf(" ✓ chunked %s → %d chunks", doc.Filename, len(chunks))
// ── Step 3: Embed ───────────────────────
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "embedding", nil)
texts := make([]string, len(chunks))
for i, c := range chunks {
texts[i] = c.Content
}
embedResult, err := ing.embedder.EmbedChunks(ctx, userID, teamID, texts)
if err != nil {
return fmt.Errorf("embed: %w", err)
}
log.Printf(" ✓ embedded %d chunks (model=%s, dim=%d, tokens=%d)",
len(chunks), embedResult.Model, embedResult.Dimensions, embedResult.InputTokens)
// Track embedding usage (role = "embedding")
ing.embedder.LogUsage(ctx, userID, nil, embedResult)
// ── Step 4: Store chunks with vectors ───
dbChunks := make([]models.KBChunk, len(chunks))
for i, c := range chunks {
dbChunks[i] = models.KBChunk{
KBID: kb.ID,
DocumentID: doc.ID,
ChunkIndex: c.Index,
Content: c.Content,
TokenCount: c.TokenCount,
Embedding: embedResult.Vectors[i],
Metadata: models.JSONMap{
"char_start": i * (cfg.ChunkSize - cfg.ChunkOverlap), // approximate
},
}
}
if err := ing.stores.KnowledgeBases.InsertChunks(ctx, dbChunks); err != nil {
return fmt.Errorf("store chunks: %w", err)
}
// ── Step 5: Update stats ────────────────
// Update document status + chunk count
if err := ing.stores.KnowledgeBases.UpdateDocumentText(ctx, doc.ID, text, len(chunks)); err != nil {
log.Printf(" ⚠ failed to update chunk count for %s: %v", doc.ID, err)
}
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "ready", nil)
// Update KB-level stats
if err := ing.stores.KnowledgeBases.UpdateStats(ctx, kb.ID); err != nil {
log.Printf(" ⚠ failed to update KB stats: %v", err)
}
// Store embedding model info in KB config if not set
if kb.EmbeddingConfig == nil || kb.EmbeddingConfig["model"] == nil {
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{
"embedding_config": models.JSONMap{
"model": embedResult.Model,
"dimensions": embedResult.Dimensions,
},
})
}
// Mark KB as active
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"})
log.Printf(" ✓ ingest complete: %s (%d chunks, %s)",
doc.Filename, len(chunks), time.Since(start).Round(time.Millisecond))
// Notify document owner of successful ingestion (v0.20.0)
if svc := notifications.Default(); svc != nil {
notifications.NotifyKBReady(svc, userID, kb.ID, kb.Name, len(chunks))
}
return nil
}
// ── Text Extraction ──────────────────────────
// extractText reads the document file and returns its text content.
// For text-based files, reads directly from object store.
// For complex formats (PDF, docx), returns an error — the extraction
// sidecar integration is planned for a future phase.
func (ing *Ingester) extractText(ctx context.Context, doc *models.KBDocument) (string, error) {
// If extracted text was already stored (e.g. from a rebuild), use it
if doc.ExtractedText != nil && *doc.ExtractedText != "" {
return *doc.ExtractedText, nil
}
// Text-based files: read directly
if textMIMETypes[doc.ContentType] {
return ing.readTextFile(ctx, doc.StorageKey)
}
// Complex document types are not yet supported for inline extraction.
// The extraction sidecar (v0.12.0) handles files but isn't yet
// wired into the KB pipeline. Planned for a future phase.
return "", fmt.Errorf("unsupported content type %q — text-based files only for now (txt, md, csv, html)", doc.ContentType)
}
// readTextFile reads a text file from the object store and returns its contents.
func (ing *Ingester) readTextFile(ctx context.Context, storageKey string) (string, error) {
rc, size, _, err := ing.objStore.Get(ctx, storageKey)
if err != nil {
return "", fmt.Errorf("read %s: %w", storageKey, err)
}
defer rc.Close()
// Guard against absurdly large files
if size > MaxTextLength+1024 {
// Read up to limit
limited := io.LimitReader(rc, MaxTextLength)
data, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("read %s: %w", storageKey, err)
}
return string(data), nil
}
data, err := io.ReadAll(rc)
if err != nil {
return "", fmt.Errorf("read %s: %w", storageKey, err)
}
return string(data), nil
}

View File

@@ -1,157 +0,0 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
)
// compactionThreshold is the minimum number of active memories before
// compaction is considered worthwhile for a user.
const compactionThreshold = 30
// compactionPrompt instructs the utility model to merge related memories.
const compactionPrompt = `You are a memory compaction assistant. Given a set of user memories (key-value facts), merge related or redundant entries into fewer, more precise facts.
Rules:
- Combine facts about the same topic into a single entry
- Remove duplicates, keeping the most specific/recent version
- Preserve all unique information — do not drop facts
- Keep keys short (2-5 words, lowercase)
- Keep values concise (1-2 sentences max)
- Set confidence based on how well-supported the merged fact is
Respond ONLY with a JSON array of merged facts:
[{"key": "...", "value": "...", "confidence": 0.0-1.0}]
If no merging is possible, return the original facts unchanged.`
// Compactor merges related memories to reduce count and improve quality.
type Compactor struct {
stores store.Stores
roleResolver *roles.Resolver
}
// NewCompactor creates a memory compaction service.
func NewCompactor(stores store.Stores, rr *roles.Resolver) *Compactor {
return &Compactor{stores: stores, roleResolver: rr}
}
// CompactResult holds the outcome of a compaction run.
type CompactResult struct {
Before int `json:"before"`
After int `json:"after"`
Merged int `json:"merged"`
Archived int `json:"archived"`
}
// Compact loads active memories for a user, sends them to the utility model
// for merging, then upserts merged entries and archives originals.
func (c *Compactor) Compact(ctx context.Context, userID string, teamID *string) (*CompactResult, error) {
if c.stores.Memories == nil {
return nil, fmt.Errorf("memory store not available")
}
// Load all active user-scope memories
memories, err := c.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: models.MemoryStatusActive,
Limit: 500,
})
if err != nil {
return nil, fmt.Errorf("list memories: %w", err)
}
if len(memories) < compactionThreshold {
return &CompactResult{Before: len(memories), After: len(memories)}, nil
}
// Build memory text for the model
var sb strings.Builder
for i, m := range memories {
sb.WriteString(fmt.Sprintf("%d. [%s] %s (confidence: %.2f)\n", i+1, m.Key, m.Value, m.Confidence))
}
apiMessages := []providers.Message{
{Role: "system", Content: compactionPrompt},
{Role: "user", Content: fmt.Sprintf("Compact these %d memories:\n\n%s", len(memories), sb.String())},
}
result, err := c.roleResolver.Complete(ctx, "utility", userID, teamID, apiMessages)
if err != nil {
return nil, fmt.Errorf("compaction completion: %w", err)
}
// Parse merged facts
merged, err := parseExtractionResponse(result.Content)
if err != nil {
return nil, fmt.Errorf("parse compaction response: %w", err)
}
if len(merged) == 0 || len(merged) >= len(memories) {
// Model returned nothing useful or didn't reduce — skip
log.Printf("🧠 memory compaction: user %s — model returned %d facts (was %d), skipping",
userID, len(merged), len(memories))
return &CompactResult{Before: len(memories), After: len(memories)}, nil
}
// Archive all original memories
archived := 0
for _, m := range memories {
if err := c.stores.Memories.Archive(ctx, m.ID); err == nil {
archived++
}
}
// Upsert merged memories
for _, fact := range merged {
mem := &models.Memory{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Key: fact.Key,
Value: fact.Value,
Confidence: fact.Confidence,
Status: models.MemoryStatusActive,
}
if err := c.stores.Memories.Upsert(ctx, mem); err != nil {
log.Printf("⚠ memory compaction upsert failed: key=%s: %v", fact.Key, err)
}
}
log.Printf("🧠 memory compaction: user %s — %d → %d memories (archived %d)",
userID, len(memories), len(merged), archived)
return &CompactResult{
Before: len(memories),
After: len(merged),
Merged: len(merged),
Archived: archived,
}, nil
}
// parseCompactionResponse parses the model's JSON array response.
// Reuses the same extractedFact struct from extractor.go.
func parseCompactionResponse(content string) ([]extractedFact, error) {
content = strings.TrimSpace(content)
// Strip markdown code fences if present
if strings.HasPrefix(content, "```") {
lines := strings.Split(content, "\n")
if len(lines) > 2 {
content = strings.Join(lines[1:len(lines)-1], "\n")
}
}
var facts []extractedFact
if err := json.Unmarshal([]byte(content), &facts); err != nil {
return nil, err
}
return facts, nil
}

View File

@@ -1,242 +0,0 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"switchboard-core/knowledge"
"switchboard-core/models"
"switchboard-core/notifications"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
)
// defaultExtractionPrompt is used when the persona has no custom prompt.
const defaultExtractionPrompt = `You are a memory extraction assistant. Analyze the following conversation and extract memorable facts about the user.
Focus on:
- Personal preferences (language, tools, frameworks, communication style)
- Technical details (deployment stack, database choices, architecture patterns)
- Project context (project names, team size, deadlines, goals)
- Biographical facts (role, company, location, experience level)
Respond ONLY with a JSON array of extracted facts. Each fact must have:
- "key": short label (2-5 words, lowercase)
- "value": the detail/fact (1-2 sentences max)
- "confidence": 0.0-1.0 how certain you are
Example:
[
{"key": "preferred language", "value": "Go with Gin framework for backend services", "confidence": 0.95},
{"key": "deployment target", "value": "Kubernetes on AWS with PostgreSQL databases", "confidence": 0.8}
]
If no memorable facts are found, respond with an empty array: []`
// maxConversationChars limits the conversation text sent for extraction.
const maxConversationChars = 30000
// minMessagesForExtraction is the minimum new messages before extraction triggers.
const minMessagesForExtraction = 6
// extractedFact is the JSON structure returned by the utility model.
type extractedFact struct {
Key string `json:"key"`
Value string `json:"value"`
Confidence float64 `json:"confidence"`
}
// Extractor analyzes conversations and extracts memorable facts.
type Extractor struct {
stores store.Stores
roleResolver *roles.Resolver
embedder *knowledge.Embedder
}
// NewExtractor creates a new memory extraction service.
func NewExtractor(stores store.Stores, rr *roles.Resolver, embedder *knowledge.Embedder) *Extractor {
return &Extractor{stores: stores, roleResolver: rr, embedder: embedder}
}
// Extract analyzes a conversation and saves extracted facts as pending_review memories.
func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, personaID string) error {
if e.stores.Memories == nil || e.stores.Messages == nil {
return fmt.Errorf("memory or message store not available")
}
// Check extraction log for last processed message
lastMessageID, _ := e.stores.Memories.GetLastExtractionMessageID(ctx, channelID, userID)
// Load recent messages for this channel
messages, err := e.stores.Messages.ListForChannel(ctx, channelID, store.ListOptions{Limit: 200})
if err != nil {
return fmt.Errorf("load messages: %w", err)
}
// Filter to new messages only (messages come back newest-first)
var newMessages []models.Message
for i := len(messages) - 1; i >= 0; i-- {
if lastMessageID == "" || messages[i].ID > lastMessageID {
newMessages = append(newMessages, messages[i])
}
}
if len(newMessages) < minMessagesForExtraction {
return nil // not enough new content
}
// Build conversation text
var sb strings.Builder
for _, m := range newMessages {
role := m.Role
if role == "user" {
role = "User"
} else {
role = "Assistant"
}
line := fmt.Sprintf("[%s]: %s\n", role, m.Content)
if sb.Len()+len(line) > maxConversationChars {
break
}
sb.WriteString(line)
}
if sb.Len() < 200 {
return nil // too little content
}
// Get extraction prompt (persona-specific or default)
prompt := defaultExtractionPrompt
if personaID != "" && e.stores.Personas != nil {
persona, err := e.stores.Personas.GetByID(ctx, personaID)
if err == nil {
// Respect persona memory_enabled flag (H6 — audit v0.28.0)
if !persona.MemoryEnabled {
return nil
}
if persona.MemoryExtractionPrompt != nil && *persona.MemoryExtractionPrompt != "" {
prompt = *persona.MemoryExtractionPrompt
}
}
}
// Call utility model via role resolver
var tID *string
if teamID != "" {
tID = &teamID
}
apiMessages := []providers.Message{
{Role: "system", Content: prompt},
{Role: "user", Content: "Analyze this conversation and extract memorable facts:\n\n" + sb.String()},
}
result, err := e.roleResolver.Complete(ctx, "utility", userID, tID, apiMessages)
if err != nil {
return fmt.Errorf("extraction completion: %w", err)
}
// Parse response
facts, err := parseExtractionResponse(result.Content)
if err != nil {
log.Printf("⚠ memory extraction parse failed for channel %s: %v", channelID, err)
return nil // don't fail on parse errors
}
if len(facts) == 0 {
log.Printf("🧠 memory extraction: channel %s → 0 facts (nothing memorable)", channelID)
}
// Determine scope
scope := models.MemoryScopeUser
ownerID := userID
var memUserID *string
if personaID != "" {
scope = models.MemoryScopePersonaUser
ownerID = personaID
memUserID = &userID
}
// Save extracted facts
saved := 0
for _, f := range facts {
if f.Key == "" || f.Value == "" || f.Confidence < 0.3 {
continue
}
mem := &models.Memory{
ID: store.NewID(),
Scope: scope,
OwnerID: ownerID,
UserID: memUserID,
Key: f.Key,
Value: f.Value,
SourceChannelID: &channelID,
Confidence: f.Confidence,
Status: models.MemoryStatusPendingReview,
}
if err := e.stores.Memories.Upsert(ctx, mem); err != nil {
log.Printf("⚠ memory save failed: %v", err)
continue
}
// Embed if available
e.embedMemory(ctx, mem, userID, tID)
saved++
}
// Update extraction log
latestID := newMessages[len(newMessages)-1].ID
err = e.stores.Memories.UpsertExtractionLog(ctx, channelID, userID, latestID, saved)
if saved > 0 {
log.Printf("✅ memory extraction: channel %s → %d facts", channelID, saved)
// Notify user about extracted memories (v0.28.2)
if svc := notifications.Default(); svc != nil {
notifications.NotifyMemoryExtracted(svc, userID, channelID, saved)
}
}
return err
}
// embedMemory generates and stores an embedding vector for a memory.
func (e *Extractor) embedMemory(ctx context.Context, m *models.Memory, userID string, teamID *string) {
if e.embedder == nil || !e.embedder.IsConfigured(ctx) {
return
}
text := m.Key + ": " + m.Value
result, err := e.embedder.EmbedChunks(ctx, userID, teamID, []string{text})
if err != nil || len(result.Vectors) == 0 {
return
}
vecJSON, _ := json.Marshal(result.Vectors[0])
_ = e.stores.Memories.SetEmbedding(ctx, m.ID, string(vecJSON))
}
// parseExtractionResponse parses the utility model's JSON response.
func parseExtractionResponse(content string) ([]extractedFact, error) {
content = strings.TrimSpace(content)
// Strip markdown code fences
if strings.HasPrefix(content, "```") {
lines := strings.Split(content, "\n")
if len(lines) > 2 {
lines = lines[1 : len(lines)-1]
content = strings.Join(lines, "\n")
}
}
var facts []extractedFact
if err := json.Unmarshal([]byte(content), &facts); err != nil {
return nil, fmt.Errorf("parse facts JSON: %w", err)
}
return facts, nil
}

View File

@@ -1,253 +0,0 @@
package memory
import (
"context"
"log"
"sync"
"time"
"switchboard-core/database"
"switchboard-core/store"
)
// ScannerConfig holds tunable parameters for the background scanner.
type ScannerConfig struct {
Interval time.Duration // tick interval (default 10m)
Concurrency int // max parallel extractions (default 2)
}
// Scanner periodically finds conversations needing extraction.
type Scanner struct {
extractor *Extractor
stores store.Stores
cfg ScannerConfig
stopCh chan struct{}
wg sync.WaitGroup
inFlight sync.Map // channelID+userID → true
}
// NewScanner creates a background extraction scanner.
func NewScanner(ext *Extractor, stores store.Stores, cfg ScannerConfig) *Scanner {
if cfg.Interval <= 0 {
cfg.Interval = 10 * time.Minute
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 2
}
return &Scanner{
extractor: ext,
stores: stores,
cfg: cfg,
stopCh: make(chan struct{}),
}
}
// Start begins the background scanning loop.
func (s *Scanner) Start() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
log.Printf("🧠 memory extraction scanner started (interval=%s, concurrency=%d)",
s.cfg.Interval, s.cfg.Concurrency)
ticker := time.NewTicker(s.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
log.Println("🧠 memory extraction scanner stopped")
return
case <-ticker.C:
s.scan()
}
}
}()
}
// Stop gracefully shuts down the scanner.
func (s *Scanner) Stop() {
close(s.stopCh)
s.wg.Wait()
}
// candidate represents a conversation that may need extraction.
type candidate struct {
ChannelID string
UserID string
TeamID string
PersonaID string // non-empty if a persona is active on the channel
}
func (s *Scanner) scan() {
ctx := context.Background()
// Check global kill switch.
// Default: ENABLED. Only disable if the key exists and is explicitly false.
// This inverts the previous behavior where a missing key blocked all extraction.
if s.stores.GlobalConfig != nil {
val, err := s.stores.GlobalConfig.Get(ctx, "memory_extraction_enabled")
if err == nil && val != nil {
if enabled, ok := val["value"].(bool); ok && !enabled {
return // explicitly disabled
}
}
// If key is missing (err != nil or val == nil), extraction is enabled.
}
// ── Extraction ──
candidates, err := s.findCandidates(ctx)
if err != nil {
log.Printf("⚠ memory scanner: find candidates: %v", err)
return
}
if len(candidates) > 0 {
sem := make(chan struct{}, s.cfg.Concurrency)
var wg sync.WaitGroup
for _, c := range candidates {
key := c.ChannelID + ":" + c.UserID
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
continue
}
sem <- struct{}{}
wg.Add(1)
go func(cand candidate) {
defer wg.Done()
defer func() { <-sem }()
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil {
log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v",
cand.ChannelID, cand.UserID, err)
}
}(c)
}
wg.Wait()
}
// ── v0.30.1: Compaction (decay + prune) ──
// Runs after extraction on every tick. Gated by memory_compaction_enabled.
s.runCompaction(ctx)
}
// runCompaction applies confidence decay and pruning to stale memories.
func (s *Scanner) runCompaction(ctx context.Context) {
if s.stores.Memories == nil {
return
}
// Check compaction kill switch (default: enabled)
if s.stores.GlobalConfig != nil {
val, err := s.stores.GlobalConfig.Get(ctx, "memory_compaction_enabled")
if err == nil && val != nil {
if enabled, ok := val["value"].(bool); ok && !enabled {
return
}
}
}
// Decay: reduce confidence for memories not updated in 7 days
olderThan := time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
decayed, err := s.stores.Memories.DecayConfidence(ctx, olderThan, 0.9)
if err != nil {
log.Printf("⚠ memory compaction: decay failed: %v", err)
} else if decayed > 0 {
log.Printf("🧠 memory compaction: decayed %d memories", decayed)
}
// Prune: archive memories with confidence below 0.15
pruned, err := s.stores.Memories.Prune(ctx, 0.15)
if err != nil {
log.Printf("⚠ memory compaction: prune failed: %v", err)
} else if pruned > 0 {
log.Printf("🧠 memory compaction: pruned %d memories", pruned)
}
}
// findCandidates queries for channels with enough new activity since last extraction.
//
// Fixes from v0.28.0 audit:
// - Uses channels.user_id (was: non-existent created_by)
// - Uses channels.team_id directly (was: JOIN to non-existent channel_users table)
// - JOINs channel_participants + personas to check memory_enabled (H6)
// - Filters out channels whose active persona has memory_enabled=false
func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) {
db := database.DB
if db == nil {
return nil, nil
}
var query string
if database.IsSQLite() {
query = `
SELECT c.id, c.user_id, COALESCE(c.team_id, ''),
COALESCE(cp.participant_id, '')
FROM channels c
LEFT JOIN channel_participants cp
ON cp.channel_id = c.id AND cp.participant_type = 'persona'
LEFT JOIN personas p
ON p.id = cp.participant_id
LEFT JOIN memory_extraction_log mel
ON mel.channel_id = c.id AND mel.user_id = c.user_id
WHERE c.user_id IS NOT NULL
AND (cp.id IS NULL OR p.memory_enabled = 1)
AND (
SELECT COUNT(*) FROM messages m
WHERE m.channel_id = c.id
AND (mel.last_message_id IS NULL OR m.id > mel.last_message_id)
) >= 8
AND (
SELECT MAX(m2.created_at) FROM messages m2 WHERE m2.channel_id = c.id
) < datetime('now', '-5 minutes')
LIMIT 20
`
} else {
query = `
SELECT c.id, c.user_id, COALESCE(c.team_id::text, ''),
COALESCE(cp.participant_id::text, '')
FROM channels c
LEFT JOIN channel_participants cp
ON cp.channel_id = c.id AND cp.participant_type = 'persona'
LEFT JOIN personas p
ON p.id = cp.participant_id
LEFT JOIN memory_extraction_log mel
ON mel.channel_id = c.id AND mel.user_id = c.user_id
WHERE c.user_id IS NOT NULL
AND (cp.id IS NULL OR p.memory_enabled = true)
AND (
SELECT COUNT(*) FROM messages m
WHERE m.channel_id = c.id
AND (mel.last_message_id IS NULL OR m.id::text > mel.last_message_id::text)
) >= 8
AND (
SELECT MAX(m2.created_at) FROM messages m2 WHERE m2.channel_id = c.id
) < now() - interval '5 minutes'
LIMIT 20
`
}
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var candidates []candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.ChannelID, &c.UserID, &c.TeamID, &c.PersonaID); err != nil {
continue
}
candidates = append(candidates, c)
}
return candidates, rows.Err()
}

View File

@@ -1,220 +0,0 @@
package mentions
import (
"sort"
"strings"
"unicode"
"switchboard-core/models"
)
// Mention represents a parsed @mention token in message content.
type Mention struct {
Raw string // "@veronica-sharpe" as written (includes @)
Name string // "veronica-sharpe" (normalized, no @)
Start int // byte offset in message content
End int // byte offset end (exclusive)
Resolved *models.ChannelModel // nil if unresolved
IsAll bool // true for @all special token
}
// Parse extracts @mentions from message content and resolves them
// against the channel's model roster.
//
// Resolution priority:
// 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe")
// 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica")
// 3. Fallback: normalized display_name match (backward compat)
// 4. @all is a special token that resolves to all roster entries
//
// @ must be at start of content or preceded by whitespace.
func Parse(content string, roster []models.ChannelModel) []Mention {
if len(roster) == 0 || !strings.Contains(content, "@") {
return nil
}
// Build handle lookup: handle → *ChannelModel
// Also build display name lookup for backward compat
type entry struct {
handle string // normalized handle
normalized string // normalized display_name
model models.ChannelModel
}
entries := make([]entry, 0, len(roster))
for _, cm := range roster {
h := normalize(cm.Handle)
dn := normalize(cm.DisplayName)
entries = append(entries, entry{handle: h, normalized: dn, model: cm})
}
// Sort by handle length descending for longest-match-first
sort.Slice(entries, func(i, j int) bool {
return len(entries[i].handle) > len(entries[j].handle)
})
// Extract @tokens
var mentions []Mention
i := 0
for i < len(content) {
if content[i] != '@' {
i++
continue
}
// @ must be at start or preceded by whitespace
if i > 0 && !unicode.IsSpace(rune(content[i-1])) {
i++
continue
}
// Extract the token after @
start := i
i++ // skip @
tokenStart := i
for i < len(content) && !unicode.IsSpace(rune(content[i])) {
i++
}
if i == tokenStart {
continue // bare @ with no token
}
// Strip trailing punctuation
tokenEnd := i
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
tokenEnd--
}
if tokenEnd == tokenStart {
continue
}
raw := content[start:tokenEnd]
name := content[tokenStart:tokenEnd]
normalizedName := normalize(name)
// Special: @all
if normalizedName == "all" {
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
Start: start,
End: tokenEnd,
IsAll: true,
})
continue
}
// 1. Exact handle match
var resolved *models.ChannelModel
for idx := range entries {
if entries[idx].handle != "" && normalizedName == entries[idx].handle {
cm := entries[idx].model
resolved = &cm
break
}
}
// 2. Prefix handle match (unambiguous only)
if resolved == nil {
var prefixMatches []int
for idx := range entries {
if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) {
prefixMatches = append(prefixMatches, idx)
}
}
if len(prefixMatches) == 1 {
cm := entries[prefixMatches[0]].model
resolved = &cm
}
}
// 3. Fallback: display_name match (backward compat)
if resolved == nil {
for idx := range entries {
if entries[idx].normalized != "" && normalizedName == entries[idx].normalized {
cm := entries[idx].model
resolved = &cm
break
}
}
// Display name prefix
if resolved == nil {
var prefixMatches []int
for idx := range entries {
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") {
prefixMatches = append(prefixMatches, idx)
}
}
if len(prefixMatches) == 0 {
for idx := range entries {
if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) {
prefixMatches = append(prefixMatches, idx)
}
}
}
if len(prefixMatches) == 1 {
cm := entries[prefixMatches[0]].model
resolved = &cm
}
}
}
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
Start: start,
End: tokenEnd,
Resolved: resolved,
})
}
return mentions
}
// ResolvedModels returns deduplicated resolved models from parsed mentions.
// Returns nil if no mentions resolved.
func ResolvedModels(mentions []Mention) []models.ChannelModel {
if len(mentions) == 0 {
return nil
}
seen := make(map[string]bool)
var result []models.ChannelModel
for _, m := range mentions {
if m.Resolved != nil && !seen[m.Resolved.ID] {
seen[m.Resolved.ID] = true
result = append(result, *m.Resolved)
}
}
return result
}
// HasAll returns true if any mention is @all.
func HasAll(mentions []Mention) bool {
for _, m := range mentions {
if m.IsAll {
return true
}
}
return false
}
// isMentionTrailingPunct returns true for punctuation that commonly
// follows an @mention but isn't part of the name.
func isMentionTrailingPunct(b byte) bool {
switch b {
case ',', '.', '!', '?', ':', ';', ')', ']', '}':
return true
}
return false
}
// normalize converts a display name to a canonical form for matching:
// lowercase, hyphens → spaces collapsed, trimmed.
func normalize(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
// Collapse multiple spaces
fields := strings.Fields(s)
return strings.Join(fields, " ")
}

View File

@@ -1,213 +0,0 @@
package mentions
import (
"testing"
"switchboard-core/models"
)
func roster() []models.ChannelModel {
return []models.ChannelModel{
{ID: "1", ChannelID: "ch1", ModelID: "anthropic/claude-3-opus", DisplayName: "Claude-3-Opus", IsDefault: true},
{ID: "2", ChannelID: "ch1", ModelID: "openai/gpt-4", DisplayName: "GPT-4"},
{ID: "3", ChannelID: "ch1", ModelID: "anthropic/claude-3-haiku", DisplayName: "Claude-3-Haiku"},
}
}
func TestParse_NoMentions(t *testing.T) {
m := Parse("Hello, how are you?", roster())
if len(m) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(m))
}
}
func TestParse_EmptyRoster(t *testing.T) {
m := Parse("@gpt-4 hello", nil)
if len(m) != 0 {
t.Fatalf("expected 0 mentions with empty roster, got %d", len(m))
}
}
func TestParse_SingleMention(t *testing.T) {
m := Parse("@GPT-4 what do you think?", roster())
if len(m) != 1 {
t.Fatalf("expected 1 mention, got %d", len(m))
}
if m[0].Name != "GPT-4" {
t.Errorf("expected Name='GPT-4', got %q", m[0].Name)
}
if m[0].Resolved == nil {
t.Fatal("expected resolved, got nil")
}
if m[0].Resolved.ID != "2" {
t.Errorf("expected resolved to GPT-4 (ID=2), got ID=%s", m[0].Resolved.ID)
}
}
func TestParse_CaseInsensitive(t *testing.T) {
m := Parse("@claude-3-opus please help", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected 1 resolved mention")
}
if m[0].Resolved.ID != "1" {
t.Errorf("expected Claude-3-Opus (ID=1), got ID=%s", m[0].Resolved.ID)
}
}
func TestParse_MultipleMentions(t *testing.T) {
m := Parse("Hey @Claude-3-Opus and @GPT-4, compare your approaches.", roster())
if len(m) != 2 {
t.Fatalf("expected 2 mentions, got %d", len(m))
}
if m[0].Resolved == nil || m[0].Resolved.ID != "1" {
t.Errorf("first mention: expected Claude-3-Opus")
}
if m[1].Resolved == nil || m[1].Resolved.ID != "2" {
t.Errorf("second mention: expected GPT-4")
}
}
func TestParse_UnresolvedMention(t *testing.T) {
m := Parse("@nonexistent-model hello", roster())
if len(m) != 1 {
t.Fatalf("expected 1 mention, got %d", len(m))
}
if m[0].Resolved != nil {
t.Errorf("expected unresolved mention, got resolved to %s", m[0].Resolved.DisplayName)
}
}
func TestParse_MixedResolvedUnresolved(t *testing.T) {
m := Parse("@GPT-4 and @fake-model what?", roster())
if len(m) != 2 {
t.Fatalf("expected 2 mentions, got %d", len(m))
}
if m[0].Resolved == nil {
t.Error("first mention should resolve")
}
if m[1].Resolved != nil {
t.Error("second mention should not resolve")
}
}
func TestParse_MentionAtStart(t *testing.T) {
m := Parse("@GPT-4", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected 1 resolved mention at start")
}
}
func TestParse_MentionInMiddle(t *testing.T) {
m := Parse("Ask @GPT-4 about this", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected 1 resolved mention in middle")
}
}
func TestParse_NoSpaceBefore(t *testing.T) {
// email@GPT-4 should NOT be parsed as a mention
m := Parse("email@GPT-4 test", roster())
if len(m) != 0 {
t.Fatalf("expected 0 mentions (no space before @), got %d", len(m))
}
}
func TestParse_BareAt(t *testing.T) {
m := Parse("@ nothing", roster())
if len(m) != 0 {
t.Fatalf("expected 0 mentions for bare @, got %d", len(m))
}
}
func TestParse_HyphenSpaceNormalization(t *testing.T) {
// Display name "Claude-3-Opus" should match "@claude_3_opus" (underscore normalization)
m := Parse("@claude_3_opus help", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected underscore-normalized match")
}
if m[0].Resolved.ID != "1" {
t.Errorf("expected Claude-3-Opus, got %s", m[0].Resolved.DisplayName)
}
}
func TestParse_ByteOffsets(t *testing.T) {
content := "Hey @GPT-4 ok"
m := Parse(content, roster())
if len(m) != 1 {
t.Fatal("expected 1 mention")
}
if m[0].Start != 4 || m[0].End != 10 {
t.Errorf("expected offsets [4,10), got [%d,%d)", m[0].Start, m[0].End)
}
if content[m[0].Start:m[0].End] != "@GPT-4" {
t.Errorf("offsets don't match: %q", content[m[0].Start:m[0].End])
}
}
func TestParse_TrailingPunctuation(t *testing.T) {
// Comma, period, exclamation should be stripped
tests := []struct {
input string
want int // expected resolved count
}{
{"@GPT-4, what?", 1},
{"@GPT-4.", 1},
{"@GPT-4!", 1},
{"@GPT-4? help", 1},
{"(@GPT-4)", 0}, // ( before @ is not whitespace — not a mention
{"( @GPT-4)", 1}, // space before @ — valid mention, ) stripped
}
for _, tt := range tests {
m := Parse(tt.input, roster())
resolved := 0
for _, mm := range m {
if mm.Resolved != nil {
resolved++
}
}
if resolved != tt.want {
t.Errorf("Parse(%q): expected %d resolved, got %d", tt.input, tt.want, resolved)
}
}
}
func TestResolvedModels_Dedup(t *testing.T) {
m := Parse("@GPT-4 do this @GPT-4 and that", roster())
resolved := ResolvedModels(m)
if len(resolved) != 1 {
t.Fatalf("expected 1 deduplicated model, got %d", len(resolved))
}
}
func TestResolvedModels_NilOnNoResolve(t *testing.T) {
m := Parse("@unknown-model test", roster())
resolved := ResolvedModels(m)
if resolved != nil {
t.Fatalf("expected nil for unresolved only, got %d", len(resolved))
}
}
func TestResolvedModels_MultipleDistinct(t *testing.T) {
m := Parse("@GPT-4 and @Claude-3-Opus compare", roster())
resolved := ResolvedModels(m)
if len(resolved) != 2 {
t.Fatalf("expected 2 distinct models, got %d", len(resolved))
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
in, want string
}{
{"Claude-3-Opus", "claude 3 opus"},
{"GPT-4", "gpt 4"},
{"gpt_4", "gpt 4"},
{" mixed--Case__Name ", "mixed case name"},
}
for _, tt := range tests {
got := normalize(tt.in)
if got != tt.want {
t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

View File

@@ -1,50 +0,0 @@
package notelinks
import (
"regexp"
"strings"
"switchboard-core/models"
)
// wikiRe matches [[Title]], [[Title|Display]], ![[Title]], and ![[Title|Display]].
// Title first char rejects [ and ] to avoid matching inside [[[triple]]].
var wikiRe = regexp.MustCompile(`(!?)\[\[([^\[\]|][^\]|]*?)(?:\|([^\]]+?))?\]\]`)
// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown content.
// Returns deduplicated links preserving first occurrence.
// Ignores matches preceded by [ (e.g. [[[triple]]]) since Go regexp lacks lookbehind.
func ExtractWikilinks(content string) []models.NoteLink {
indices := wikiRe.FindAllStringSubmatchIndex(content, -1)
seen := make(map[string]bool)
var links []models.NoteLink
for _, idx := range indices {
matchStart := idx[0] // start of full match (including optional !)
// Skip if preceded by [ — rejects [[[triple]]] patterns
if matchStart > 0 && content[matchStart-1] == '[' {
continue
}
// Extract submatches by index pairs: idx[2*n], idx[2*n+1]
title := strings.TrimSpace(content[idx[4]:idx[5]]) // group 2: title
key := strings.ToLower(title)
if key == "" || seen[key] {
continue
}
seen[key] = true
link := models.NoteLink{
TargetTitle: title,
IsTransclusion: content[idx[2]:idx[3]] == "!", // group 1
}
// group 3: display text (optional — idx[6:7] may be -1)
if idx[6] >= 0 && idx[7] >= 0 {
link.DisplayText = strings.TrimSpace(content[idx[6]:idx[7]])
}
links = append(links, link)
}
return links
}

View File

@@ -1,122 +0,0 @@
package notelinks
import (
"testing"
)
func TestExtractWikilinks(t *testing.T) {
tests := []struct {
name string
content string
want int
checks func(t *testing.T, links []struct{ title, display string; transclusion bool })
}{
{
name: "no links",
content: "Just some plain text with [single brackets]",
want: 0,
},
{
name: "single link",
content: "See [[Project Notes]] for details",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Project Notes" {
t.Errorf("title = %q, want %q", links[0].title, "Project Notes")
}
if links[0].transclusion {
t.Error("should not be transclusion")
}
},
},
{
name: "link with display text",
content: "Check the [[Architecture Doc|arch doc]] here",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Architecture Doc" {
t.Errorf("title = %q", links[0].title)
}
if links[0].display != "arch doc" {
t.Errorf("display = %q", links[0].display)
}
},
},
{
name: "transclusion",
content: "Embed this: ![[Meeting Notes]]",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Meeting Notes" {
t.Errorf("title = %q", links[0].title)
}
if !links[0].transclusion {
t.Error("should be transclusion")
}
},
},
{
name: "multiple links deduplicated",
content: "See [[Alpha]] and [[Beta]] and [[Alpha]] again",
want: 2,
},
{
name: "case-insensitive dedup",
content: "See [[Project]] and [[project]]",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Project" {
t.Errorf("title = %q, want first occurrence", links[0].title)
}
},
},
{
name: "mixed links and transclusions",
content: "Link to [[Alpha]], embed ![[Beta|b]], and [[Gamma]]",
want: 3,
},
{
name: "whitespace trimmed",
content: "See [[ Spaced Title ]] here",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Spaced Title" {
t.Errorf("title = %q, want trimmed", links[0].title)
}
},
},
{
name: "nested brackets ignored",
content: "Not a link: [[[triple]]] but [[Valid]] is",
want: 1,
checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) {
if links[0].title != "Valid" {
t.Errorf("title = %q, want Valid", links[0].title)
}
},
},
{
name: "empty brackets ignored",
content: "Empty [[]] should not match",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
links := ExtractWikilinks(tt.content)
if len(links) != tt.want {
t.Fatalf("got %d links, want %d", len(links), tt.want)
}
if tt.checks != nil {
simplified := make([]struct{ title, display string; transclusion bool }, len(links))
for i, l := range links {
simplified[i] = struct{ title, display string; transclusion bool }{
title: l.TargetTitle, display: l.DisplayText, transclusion: l.IsTransclusion,
}
}
tt.checks(t, simplified)
}
})
}
}

View File

@@ -1,538 +0,0 @@
package providers
import (
"switchboard-core/capabilities"
"switchboard-core/models"
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
const anthropicDefaultEndpoint = "https://api.anthropic.com"
const anthropicAPIVersion = "2023-06-01"
// AnthropicProvider handles the Anthropic Messages API.
type AnthropicProvider struct{}
func (p *AnthropicProvider) ID() string { return "anthropic" }
// ── Chat Completion (non-streaming) ─────────
func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
req.Stream = false
body, err := p.doRequest(ctx, cfg, req)
if err != nil {
return nil, err
}
defer body.Close()
var resp anthropicResponse
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
result := &CompletionResponse{
Model: resp.Model,
FinishReason: resp.StopReason,
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
CacheCreationTokens: resp.Usage.CacheCreationInputTokens,
CacheReadTokens: resp.Usage.CacheReadInputTokens,
}
// Extract text and tool_use blocks
for _, block := range resp.Content {
switch block.Type {
case "text":
result.Content += block.Text
case "tool_use":
argsJSON, _ := json.Marshal(block.Input)
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: block.ID,
Type: "function",
Function: FunctionCall{
Name: block.Name,
Arguments: string(argsJSON),
},
})
}
}
// Normalize stop reason
if resp.StopReason == "tool_use" {
result.FinishReason = "tool_calls"
}
return result, nil
}
// ── Stream Completion ───────────────────────
func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
req.Stream = true
body, err := p.doRequest(ctx, cfg, req)
if err != nil {
return nil, err
}
ch := make(chan StreamEvent, 64)
go func() {
defer close(ch)
defer body.Close()
var toolCalls []ToolCall
var currentToolIdx int = -1
// Usage accumulates across stream events
var inputTokens, outputTokens, cacheCreation, cacheRead int
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
var event anthropicStreamEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
switch event.Type {
case "message_start":
// Capture input token counts (including cache)
if event.Message != nil {
inputTokens = event.Message.Usage.InputTokens
cacheCreation = event.Message.Usage.CacheCreationInputTokens
cacheRead = event.Message.Usage.CacheReadInputTokens
}
case "content_block_start":
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
currentToolIdx++
toolCalls = append(toolCalls, ToolCall{
ID: event.ContentBlock.ID,
Type: "function",
Function: FunctionCall{
Name: event.ContentBlock.Name,
Arguments: "",
},
})
}
// thinking blocks (extended thinking, v0.22.1) — no action on start,
// deltas arrive as thinking_delta in content_block_delta
case "content_block_delta":
if event.Delta.Type == "text_delta" {
ch <- StreamEvent{Delta: event.Delta.Text}
} else if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
// Extended thinking content → Reasoning field
ch <- StreamEvent{Reasoning: event.Delta.Thinking}
} else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 {
toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON
}
case "message_delta":
// Capture output tokens
if event.Usage != nil {
outputTokens = event.Usage.OutputTokens
}
stopReason := event.Delta.StopReason
ev := StreamEvent{
Done: true,
FinishReason: stopReason,
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheCreationTokens: cacheCreation,
CacheReadTokens: cacheRead,
}
if stopReason == "tool_use" {
ev.FinishReason = "tool_calls"
ev.ToolCalls = toolCalls
}
ch <- ev
case "message_stop":
ch <- StreamEvent{Done: true}
return
case "error":
ch <- StreamEvent{
Error: fmt.Errorf("anthropic stream error: %s", data),
}
return
}
}
if err := scanner.Err(); err != nil {
ch <- StreamEvent{Error: err}
}
}()
return ch, nil
}
// ── List Models ─────────────────────────────
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
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"},
}
out := make([]Model, 0, len(modelIDs))
for _, m := range modelIDs {
caps := capabilities.InferCapabilities(m.id)
out = append(out, Model{
ID: m.id,
Name: m.name,
OwnedBy: "anthropic",
Capabilities: caps,
})
}
return out, nil
}
// ── Embeddings ─────────────────────────────
// Embed is not supported by the Anthropic API.
func (p *AnthropicProvider) Embed(_ context.Context, _ ProviderConfig, _ EmbeddingRequest) (*EmbeddingResponse, error) {
return nil, ErrNotSupported
}
// ── HTTP Layer ──────────────────────────────
func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
endpoint := cfg.Endpoint
if endpoint == "" {
endpoint = anthropicDefaultEndpoint
}
endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages"
// Separate system message and convert to Anthropic format
var system string
messages := make([]anthropicMessage, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == "system" {
system = m.Content
continue
}
antMsg := anthropicMessage{Role: m.Role}
// ── Foreign persona rewrite (v0.23.0) ──
// Anthropic doesn't support the "name" field on messages.
// Rewrite named assistant messages (from other personas) to user
// role with attribution so Anthropic maintains alternation and
// the model understands it's a multi-participant conversation.
if m.Role == "assistant" && m.Name != "" {
antMsg.Role = "user"
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: fmt.Sprintf("[%s responded:]\n%s", m.Name, m.Content)},
}
messages = append(messages, antMsg)
continue
}
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
// Assistant with tool calls → mixed content blocks
if m.Content != "" {
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: m.Content},
}
} else {
antMsg.Content = []anthropicContentBlock{}
}
for _, tc := range m.ToolCalls {
var input json.RawMessage
if tc.Function.Arguments != "" {
input = json.RawMessage(tc.Function.Arguments)
} else {
input = json.RawMessage("{}")
}
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
} else if m.Role == "tool" {
// Tool results → user message with tool_result block
antMsg.Role = "user"
antMsg.Content = []anthropicContentBlock{
{
Type: "tool_result",
ToolUseID: m.ToolCallID,
Content: m.Content,
},
}
} else if len(m.ContentParts) > 0 && m.Role == "user" {
// Multimodal user message: convert ContentParts to Anthropic blocks
for _, p := range m.ContentParts {
switch p.Type {
case "image_url":
if p.ImageURL != nil {
// Parse data URI: "data:image/jpeg;base64,{data}"
mediaType, b64Data := parseDataURI(p.ImageURL.URL)
if b64Data != "" {
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
Type: "image",
Source: &anthropicImageSource{
Type: "base64",
MediaType: mediaType,
Data: b64Data,
},
})
}
}
default: // "text", "document"
if p.Text != "" {
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
Type: "text",
Text: p.Text,
})
}
}
}
if len(antMsg.Content) == 0 {
// Fallback: shouldn't happen, but safety net
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: m.Content},
}
}
} else {
// Regular text message
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: m.Content},
}
}
messages = append(messages, antMsg)
}
// Merge consecutive user messages (Anthropic requires alternating roles)
messages = mergeConsecutiveUserMessages(messages)
antReq := anthropicRequest{
Model: req.Model,
Messages: messages,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
}
if system != "" {
antReq.System = system
}
if antReq.MaxTokens == 0 {
antReq.MaxTokens = capabilities.ResolveMaxOutput(req.Model, models.ModelCapabilities{})
}
if req.Temperature != nil {
antReq.Temperature = req.Temperature
}
if req.TopP != nil {
antReq.TopP = req.TopP
}
// Add tools in Anthropic format
for _, t := range req.Tools {
antReq.Tools = append(antReq.Tools, anthropicToolDef{
Name: t.Function.Name,
Description: t.Function.Description,
InputSchema: t.Function.Parameters,
})
}
body, err := json.Marshal(antReq)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
// Merge hook-supplied extra fields into wire JSON (v0.22.1)
// This is how extended thinking config gets injected.
if len(req.ExtraBody) > 0 {
body, err = mergeExtraBody(body, req.ExtraBody)
if err != nil {
return nil, fmt.Errorf("merge extra body: %w", err)
}
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", cfg.APIKey)
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
// Extended thinking requires the output-128k beta header
if len(req.ExtraBody) > 0 {
if _, hasThinking := req.ExtraBody["thinking"]; hasThinking {
httpReq.Header.Set("anthropic-beta", "output-128k-2025-02-19")
}
}
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("provider request: %w", err)
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b))
}
return resp.Body, nil
}
// mergeConsecutiveUserMessages combines consecutive user-role messages.
// Anthropic requires strictly alternating user/assistant roles.
func mergeConsecutiveUserMessages(msgs []anthropicMessage) []anthropicMessage {
if len(msgs) <= 1 {
return msgs
}
merged := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
if len(merged) > 0 && merged[len(merged)-1].Role == "user" && m.Role == "user" {
merged[len(merged)-1].Content = append(merged[len(merged)-1].Content, m.Content...)
} else {
merged = append(merged, m)
}
}
return merged
}
// ── Anthropic Wire Types ────────────────────
type anthropicContentBlock struct {
Type string `json:"type"`
// text
Text string `json:"text,omitempty"`
// image (type="image")
Source *anthropicImageSource `json:"source,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
}
// anthropicImageSource is the Anthropic-native image format.
// Anthropic uses {"type":"base64","media_type":"image/jpeg","data":"..."}
// rather than OpenAI's data URI approach.
type anthropicImageSource struct {
Type string `json:"type"` // "base64"
MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
Data string `json:"data"` // raw base64 (no data: prefix)
}
type anthropicMessage struct {
Role string `json:"role"`
Content []anthropicContentBlock `json:"content"`
}
type anthropicToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
type anthropicRequest struct {
Model string `json:"model"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
Tools []anthropicToolDef `json:"tools,omitempty"`
}
type anthropicResponse struct {
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
} `json:"content"`
Usage anthropicUsage `json:"usage"`
}
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}
type anthropicStreamEvent struct {
Type string `json:"type"`
Message *struct {
Usage anthropicUsage `json:"usage"`
} `json:"message,omitempty"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"` // extended thinking delta (v0.22.1)
StopReason string `json:"stop_reason"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
Usage *struct {
OutputTokens int `json:"output_tokens"`
} `json:"usage,omitempty"`
ContentBlock *struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"content_block,omitempty"`
}
// parseDataURI splits a data URI into media type and base64 data.
// Input: "data:image/jpeg;base64,/9j/4AAQ..."
// Output: "image/jpeg", "/9j/4AAQ..."
// Returns empty strings if the URI is malformed.
func parseDataURI(uri string) (mediaType, data string) {
// Strip "data:" prefix
if !strings.HasPrefix(uri, "data:") {
return "", ""
}
rest := uri[5:]
// Split on comma (separates header from data)
commaIdx := strings.Index(rest, ",")
if commaIdx < 0 {
return "", ""
}
header := rest[:commaIdx]
data = rest[commaIdx+1:]
// Extract media type from header (before ";base64")
if semiIdx := strings.Index(header, ";"); semiIdx >= 0 {
mediaType = header[:semiIdx]
} else {
mediaType = header
}
return mediaType, data
}

View File

@@ -1,132 +0,0 @@
// Package providers — custom.go
//
// v0.29.1 CS4: Config-file provider types. Registers OpenAI-compatible
// provider types from a JSON file at startup. No code deploy needed to
// add Ollama, LiteLLM, vLLM, or any other OpenAI-compatible endpoint.
//
// File format (array of objects):
//
// [
// {
// "id": "ollama",
// "name": "Ollama",
// "description": "Local Ollama instance",
// "default_endpoint": "http://localhost:11434/v1",
// "api": "openai"
// },
// {
// "id": "litellm",
// "name": "LiteLLM Proxy",
// "description": "LiteLLM unified proxy",
// "default_endpoint": "http://litellm:4000/v1",
// "api": "openai"
// }
// ]
//
// Fields:
// - id: Unique provider type ID (used in provider_configs.provider column)
// - name: Human-readable label for admin UI
// - description: Help text shown in provider creation form
// - default_endpoint: Pre-filled endpoint URL for new configs
// - api: Wire protocol — "openai" (required, only supported value for now)
//
// The api field determines which Provider implementation handles
// requests. Currently only "openai" is supported (covers Ollama,
// LiteLLM, vLLM, LocalAI, and any other OpenAI-compatible API).
// Future: "anthropic" for Anthropic-compatible proxies.
//
// Built-in provider IDs (openai, anthropic, venice, openrouter) cannot
// be overridden by config-file entries — they are silently skipped.
package providers
import (
"encoding/json"
"fmt"
"log"
"os"
)
// CustomProviderType represents one entry in the provider types JSON file.
type CustomProviderType struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DefaultEndpoint string `json:"default_endpoint"`
API string `json:"api"` // "openai" (required)
}
// builtinIDs is the set of provider IDs registered by Init().
// Config-file entries with these IDs are silently skipped.
var builtinIDs = map[string]bool{
"openai": true,
"anthropic": true,
"venice": true,
"openrouter": true,
}
// LoadCustomTypes reads a JSON file of provider type definitions and
// registers each as a new provider type. Only OpenAI-compatible APIs
// are currently supported (api="openai").
//
// Returns the number of providers registered. Errors are non-fatal —
// individual invalid entries are logged and skipped.
//
// Call after Init() so that built-in types are already registered
// and can be detected for skip logic.
func LoadCustomTypes(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("reading provider types file: %w", err)
}
var entries []CustomProviderType
if err := json.Unmarshal(data, &entries); err != nil {
return 0, fmt.Errorf("parsing provider types file: %w", err)
}
registered := 0
for _, entry := range entries {
// Validate required fields
if entry.ID == "" {
log.Printf("⚠️ custom provider: skipping entry with empty id")
continue
}
if entry.Name == "" {
log.Printf("⚠️ custom provider %q: skipping — name is required", entry.ID)
continue
}
if entry.API == "" {
log.Printf("⚠️ custom provider %q: skipping — api is required", entry.ID)
continue
}
// Skip built-in provider IDs
if builtinIDs[entry.ID] {
log.Printf("⚠️ custom provider %q: skipping — conflicts with built-in provider", entry.ID)
continue
}
// Resolve API implementation
var impl Provider
switch entry.API {
case "openai":
impl = &OpenAIProvider{}
default:
log.Printf("⚠️ custom provider %q: unsupported api %q (only \"openai\" supported)", entry.ID, entry.API)
continue
}
RegisterType(ProviderTypeMeta{
ID: entry.ID,
Name: entry.Name,
Description: entry.Description,
DefaultEndpoint: entry.DefaultEndpoint,
ProfileSchema: GetProfileSchema(entry.API), // inherit schema from API type
}, impl)
registered++
log.Printf(" 📦 Custom provider type: %s (%s) → %s", entry.ID, entry.Name, entry.DefaultEndpoint)
}
return registered, nil
}

View File

@@ -1,161 +0,0 @@
package providers
import (
"os"
"path/filepath"
"testing"
)
func writeTempJSON(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "provider_types.json")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
return path
}
func TestLoadCustomTypes_Valid(t *testing.T) {
path := writeTempJSON(t, `[
{
"id": "test-ollama",
"name": "Test Ollama",
"description": "Local test",
"default_endpoint": "http://localhost:11434/v1",
"api": "openai"
},
{
"id": "test-litellm",
"name": "Test LiteLLM",
"description": "LiteLLM proxy",
"default_endpoint": "http://litellm:4000/v1",
"api": "openai"
}
]`)
n, err := LoadCustomTypes(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 2 {
t.Errorf("registered = %d, want 2", n)
}
// Verify they're in the registry
p, err := Get("test-ollama")
if err != nil {
t.Errorf("test-ollama not registered: %v", err)
}
if p == nil {
t.Error("test-ollama provider is nil")
}
// Verify type metadata
types := ListTypes()
found := false
for _, m := range types {
if m.ID == "test-litellm" {
found = true
if m.DefaultEndpoint != "http://litellm:4000/v1" {
t.Errorf("endpoint = %q", m.DefaultEndpoint)
}
}
}
if !found {
t.Error("test-litellm not in ListTypes()")
}
}
func TestLoadCustomTypes_SkipsBuiltins(t *testing.T) {
path := writeTempJSON(t, `[
{
"id": "openai",
"name": "Override OpenAI",
"description": "Should be skipped",
"default_endpoint": "http://evil.com",
"api": "openai"
},
{
"id": "test-custom-ok",
"name": "Valid Custom",
"description": "Should register",
"default_endpoint": "http://ok.com",
"api": "openai"
}
]`)
n, err := LoadCustomTypes(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 1 {
t.Errorf("registered = %d, want 1 (openai skipped)", n)
}
}
func TestLoadCustomTypes_SkipsInvalid(t *testing.T) {
path := writeTempJSON(t, `[
{"id": "", "name": "No ID", "api": "openai"},
{"id": "test-no-name", "name": "", "api": "openai"},
{"id": "test-no-api", "name": "No API", "api": ""},
{"id": "test-bad-api", "name": "Bad API", "api": "grpc", "default_endpoint": "http://x"},
{"id": "test-valid-entry", "name": "Valid", "description": "ok", "default_endpoint": "http://ok", "api": "openai"}
]`)
n, err := LoadCustomTypes(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 1 {
t.Errorf("registered = %d, want 1 (4 invalid skipped)", n)
}
}
func TestLoadCustomTypes_EmptyArray(t *testing.T) {
path := writeTempJSON(t, `[]`)
n, err := LoadCustomTypes(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 0 {
t.Errorf("registered = %d, want 0", n)
}
}
func TestLoadCustomTypes_InvalidJSON(t *testing.T) {
path := writeTempJSON(t, `not json`)
_, err := LoadCustomTypes(path)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestLoadCustomTypes_FileNotFound(t *testing.T) {
_, err := LoadCustomTypes("/nonexistent/path/providers.json")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestLoadCustomTypes_NoDefaultEndpoint(t *testing.T) {
// default_endpoint is optional — should still register
path := writeTempJSON(t, `[
{
"id": "test-no-endpoint",
"name": "No Endpoint",
"description": "User must fill in endpoint",
"api": "openai"
}
]`)
n, err := LoadCustomTypes(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 1 {
t.Errorf("registered = %d, want 1", n)
}
}

View File

@@ -1,296 +0,0 @@
package providers
import (
"encoding/json"
"strings"
)
// Hooks defines provider-specific request/response transforms.
// Implementations read from ProviderConfig.Settings (populated from the
// provider_configs.settings JSONB column) to decorate requests and
// normalize responses. This replaces hardcoded provider switch statements.
type Hooks interface {
// PreRequest modifies the canonical request before dispatch.
// May inject system prompt prefixes, set ExtraBody fields,
// and adjust request parameters based on provider settings.
PreRequest(cfg ProviderConfig, req *CompletionRequest)
// PostStreamEvent normalizes a streaming event after the provider
// emits it. For example, extracting thinking output from content.
PostStreamEvent(cfg ProviderConfig, event *StreamEvent)
}
// ── Hook Registry ───────────────────────────
var hooks = map[string]Hooks{
"openai": &OpenAIHooks{},
"anthropic": &AnthropicHooks{},
"venice": &VeniceHooks{},
"openrouter": &OpenRouterHooks{},
}
// GetHooks returns the hooks for a provider type.
// Returns nil for unknown types (no transforms applied).
func GetHooks(providerType string) Hooks {
return hooks[providerType]
}
// RegisterHooks adds or replaces hooks for a provider type.
func RegisterHooks(providerType string, h Hooks) {
hooks[providerType] = h
}
// ── Settings Helpers ────────────────────────
func settingString(s map[string]interface{}, key string) string {
if v, ok := s[key]; ok {
if str, ok := v.(string); ok {
return str
}
}
return ""
}
func settingBool(s map[string]interface{}, key string) bool {
if v, ok := s[key]; ok {
switch b := v.(type) {
case bool:
return b
case string:
return b == "true"
}
}
return false
}
func settingInt(s map[string]interface{}, key string, fallback int) int {
if v, ok := s[key]; ok {
switch n := v.(type) {
case float64:
return int(n) // JSON numbers decode as float64
case int:
return n
}
}
return fallback
}
func settingFloat(s map[string]interface{}, key string) (float64, bool) {
if v, ok := s[key]; ok {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
}
}
return 0, false
}
// ── Common: System Prompt Prefix ────────────
// prependSystemPrompt injects a provider-level system prompt prefix
// into the first system message, or creates one if none exists.
func prependSystemPrompt(prefix string, req *CompletionRequest) {
if prefix == "" {
return
}
for i, m := range req.Messages {
if m.Role == "system" {
req.Messages[i].Content = prefix + "\n\n" + m.Content
return
}
}
// No system message — insert at position 0
req.Messages = append([]Message{{Role: "system", Content: prefix}}, req.Messages...)
}
// ── OpenAI Hooks ────────────────────────────
type OpenAIHooks struct{}
func (h *OpenAIHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) {
s := cfg.Settings
if s == nil {
return
}
prependSystemPrompt(settingString(s, "system_prompt_prefix"), req)
if req.ExtraBody == nil {
req.ExtraBody = make(map[string]interface{})
}
if v, ok := settingFloat(s, "frequency_penalty"); ok {
req.ExtraBody["frequency_penalty"] = v
}
if v, ok := settingFloat(s, "presence_penalty"); ok {
req.ExtraBody["presence_penalty"] = v
}
}
func (h *OpenAIHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) {
// No post-processing needed for vanilla OpenAI.
}
// ── Anthropic Hooks ─────────────────────────
type AnthropicHooks struct{}
func (h *AnthropicHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) {
s := cfg.Settings
if s == nil {
return
}
prependSystemPrompt(settingString(s, "system_prompt_prefix"), req)
if settingBool(s, "extended_thinking") {
budget := settingInt(s, "thinking_budget", 10000)
if req.ExtraBody == nil {
req.ExtraBody = make(map[string]interface{})
}
req.ExtraBody["thinking"] = map[string]interface{}{
"type": "enabled",
"budget_tokens": budget,
}
// Anthropic requires temperature=1 with extended thinking, and
// thinking is incompatible with temperature overrides.
req.Temperature = nil
}
}
func (h *AnthropicHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) {
// Anthropic streaming already normalizes thinking → Reasoning field.
}
// ── Venice Hooks ────────────────────────────
type VeniceHooks struct{}
func (h *VeniceHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) {
s := cfg.Settings
if s == nil {
return
}
prependSystemPrompt(settingString(s, "system_prompt_prefix"), req)
// Build venice_parameters from settings
veniceParams := map[string]interface{}{}
hasParams := false
if settingBool(s, "enable_web_search") {
veniceParams["enable_web_search"] = "always"
hasParams = true
}
// include_venice_system_prompt defaults to true.
// Only set to false when explicitly configured.
if v, ok := s["include_venice_system_prompt"]; ok {
if b, ok := v.(bool); ok && !b {
veniceParams["include_venice_system_prompt"] = false
hasParams = true
}
}
if settingBool(s, "enable_thinking") {
// Thinking requires disabling Venice system prompt
veniceParams["include_venice_system_prompt"] = false
hasParams = true
}
if hasParams {
if req.ExtraBody == nil {
req.ExtraBody = make(map[string]interface{})
}
req.ExtraBody["venice_parameters"] = veniceParams
}
}
func (h *VeniceHooks) PostStreamEvent(cfg ProviderConfig, event *StreamEvent) {
// Venice thinking output arrives in the regular content stream
// wrapped in <think>...</think> tags. The stream_loop already handles
// <think> extraction from reasoning_content, so we extract Venice
// thinking from Delta → Reasoning when enable_thinking is on.
if cfg.Settings == nil || !settingBool(cfg.Settings, "enable_thinking") {
return
}
// Venice sends thinking content inline in Delta. The frontend
// expects it in the Reasoning field. We detect <think> blocks
// and move them.
if event.Delta != "" && strings.Contains(event.Delta, "<think>") {
// Full block in one chunk (rare but handle it)
event.Reasoning += event.Delta
event.Delta = ""
}
}
// ── OpenRouter Hooks ────────────────────────
type OpenRouterHooks struct{}
func (h *OpenRouterHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) {
s := cfg.Settings
if s == nil {
return
}
prependSystemPrompt(settingString(s, "system_prompt_prefix"), req)
// OpenRouter route/transforms go in provider preferences header.
// These are sent as JSON in the X-Provider-Preferences header by
// the OpenAI doRequest (reads from ExtraBody["provider_preferences"]).
route := settingString(s, "route")
requireParams := settingBool(s, "require_parameters")
if route != "" || requireParams {
prefs := map[string]interface{}{}
if route != "" && route != "auto" {
prefs["route"] = route
}
if requireParams {
prefs["require_parameters"] = true
}
// Serialize for the HTTP header (OpenRouter reads from extra headers)
prefsJSON, _ := json.Marshal(prefs)
if cfg.CustomHeaders == nil {
cfg.CustomHeaders = make(map[string]string)
}
cfg.CustomHeaders["X-Provider-Preferences"] = string(prefsJSON)
}
}
func (h *OpenRouterHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) {
// No post-processing needed. OpenRouter returns standard OAI format.
}
// ── Persona Override Merge ───────────────────
// MergePersonaSettings merges persona-level setting overrides onto
// provider-level settings. Persona values take priority except for
// fields marked ProviderOnly in the schema.
func MergePersonaSettings(providerType string, providerSettings, personaOverrides map[string]interface{}) map[string]interface{} {
if len(personaOverrides) == 0 {
return providerSettings
}
schema := GetProfileSchema(providerType)
providerOnly := make(map[string]bool, len(schema.Fields))
for _, f := range schema.Fields {
if f.ProviderOnly {
providerOnly[f.Key] = true
}
}
merged := make(map[string]interface{}, len(providerSettings)+len(personaOverrides))
for k, v := range providerSettings {
merged[k] = v
}
for k, v := range personaOverrides {
if !providerOnly[k] {
merged[k] = v
}
}
return merged
}

View File

@@ -1,340 +0,0 @@
package providers
import (
"strings"
"testing"
)
// ── Profile Schema Tests ────────────────────
func TestGetProfileSchema_BuiltIn(t *testing.T) {
for _, id := range []string{"openai", "anthropic", "venice", "openrouter"} {
s := GetProfileSchema(id)
if len(s.Fields) == 0 {
t.Errorf("GetProfileSchema(%q) returned empty fields", id)
}
// Every schema should have system_prompt_prefix
found := false
for _, f := range s.Fields {
if f.Key == "system_prompt_prefix" {
found = true
}
}
if !found {
t.Errorf("GetProfileSchema(%q) missing system_prompt_prefix field", id)
}
}
}
func TestGetProfileSchema_UnknownFallsToOpenAI(t *testing.T) {
s := GetProfileSchema("unknown-provider")
oai := GetProfileSchema("openai")
if len(s.Fields) != len(oai.Fields) {
t.Errorf("Unknown provider should fall back to openai schema, got %d fields, want %d", len(s.Fields), len(oai.Fields))
}
}
// ── Hook PreRequest Tests ───────────────────
func TestOpenAIHooks_SystemPromptPrefix(t *testing.T) {
h := &OpenAIHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"system_prompt_prefix": "You are a helpful pirate.",
},
}
req := &CompletionRequest{
Messages: []Message{
{Role: "system", Content: "Be concise."},
{Role: "user", Content: "Hello"},
},
}
h.PreRequest(cfg, req)
if req.Messages[0].Content != "You are a helpful pirate.\n\nBe concise." {
t.Errorf("system prompt prefix not prepended, got: %s", req.Messages[0].Content)
}
}
func TestOpenAIHooks_SystemPromptPrefix_NoExisting(t *testing.T) {
h := &OpenAIHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"system_prompt_prefix": "Be a pirate.",
},
}
req := &CompletionRequest{
Messages: []Message{
{Role: "user", Content: "Hello"},
},
}
h.PreRequest(cfg, req)
if len(req.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(req.Messages))
}
if req.Messages[0].Role != "system" || req.Messages[0].Content != "Be a pirate." {
t.Errorf("expected injected system message, got role=%s content=%s", req.Messages[0].Role, req.Messages[0].Content)
}
}
func TestOpenAIHooks_FrequencyPenalty(t *testing.T) {
h := &OpenAIHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"frequency_penalty": 0.5,
},
}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hello"}}}
h.PreRequest(cfg, req)
if req.ExtraBody == nil {
t.Fatal("ExtraBody should be set")
}
if v, ok := req.ExtraBody["frequency_penalty"].(float64); !ok || v != 0.5 {
t.Errorf("frequency_penalty: got %v, want 0.5", req.ExtraBody["frequency_penalty"])
}
}
func TestOpenAIHooks_NilSettings(t *testing.T) {
h := &OpenAIHooks{}
cfg := ProviderConfig{Settings: nil}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hello"}}}
h.PreRequest(cfg, req) // should not panic
if req.ExtraBody != nil {
t.Errorf("ExtraBody should be nil with no settings")
}
}
func TestAnthropicHooks_ExtendedThinking(t *testing.T) {
h := &AnthropicHooks{}
temp := 0.7
cfg := ProviderConfig{
Settings: map[string]interface{}{
"extended_thinking": true,
"thinking_budget": float64(20000), // JSON numbers are float64
},
}
req := &CompletionRequest{
Messages: []Message{{Role: "user", Content: "Think hard"}},
Temperature: &temp,
}
h.PreRequest(cfg, req)
if req.Temperature != nil {
t.Error("Temperature should be cleared for extended thinking")
}
if req.ExtraBody == nil {
t.Fatal("ExtraBody should be set")
}
thinking, ok := req.ExtraBody["thinking"].(map[string]interface{})
if !ok {
t.Fatal("ExtraBody[thinking] should be map")
}
if thinking["type"] != "enabled" {
t.Errorf("thinking.type = %v, want enabled", thinking["type"])
}
if thinking["budget_tokens"] != 20000 {
t.Errorf("thinking.budget_tokens = %v, want 20000", thinking["budget_tokens"])
}
}
func TestAnthropicHooks_ThinkingDefaultBudget(t *testing.T) {
h := &AnthropicHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"extended_thinking": true,
// no thinking_budget → should use default 10000
},
}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Think"}}}
h.PreRequest(cfg, req)
thinking := req.ExtraBody["thinking"].(map[string]interface{})
if thinking["budget_tokens"] != 10000 {
t.Errorf("default budget = %v, want 10000", thinking["budget_tokens"])
}
}
func TestAnthropicHooks_NoThinking(t *testing.T) {
h := &AnthropicHooks{}
temp := 0.7
cfg := ProviderConfig{
Settings: map[string]interface{}{
"extended_thinking": false,
},
}
req := &CompletionRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
Temperature: &temp,
}
h.PreRequest(cfg, req)
if req.ExtraBody != nil {
t.Error("ExtraBody should be nil when thinking is disabled")
}
if req.Temperature == nil {
t.Error("Temperature should be preserved when thinking is off")
}
}
func TestVeniceHooks_WebSearch(t *testing.T) {
h := &VeniceHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"enable_web_search": true,
},
}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Search for news"}}}
h.PreRequest(cfg, req)
vp, ok := req.ExtraBody["venice_parameters"].(map[string]interface{})
if !ok {
t.Fatal("venice_parameters missing from ExtraBody")
}
if vp["enable_web_search"] != "always" {
t.Errorf("enable_web_search = %v, want 'always'", vp["enable_web_search"])
}
}
func TestVeniceHooks_ThinkingDisablesSystemPrompt(t *testing.T) {
h := &VeniceHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"enable_thinking": true,
},
}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Think"}}}
h.PreRequest(cfg, req)
vp := req.ExtraBody["venice_parameters"].(map[string]interface{})
if vp["include_venice_system_prompt"] != false {
t.Error("Thinking mode should set include_venice_system_prompt=false")
}
}
func TestVeniceHooks_NoSettings(t *testing.T) {
h := &VeniceHooks{}
cfg := ProviderConfig{Settings: map[string]interface{}{}}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hi"}}}
h.PreRequest(cfg, req)
if req.ExtraBody != nil {
t.Error("ExtraBody should be nil with empty settings")
}
}
func TestOpenRouterHooks_Route(t *testing.T) {
h := &OpenRouterHooks{}
cfg := ProviderConfig{
Settings: map[string]interface{}{
"route": "fallback",
"require_parameters": true,
},
CustomHeaders: map[string]string{},
}
req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hi"}}}
h.PreRequest(cfg, req)
// Note: OpenRouter hooks set headers on the cfg copy, which is local.
// The real effect is tested via the serialized header value.
// Since cfg is passed by value in hooks, this test verifies the logic runs without panic.
}
// ── GetHooks Tests ──────────────────────────
func TestGetHooks_BuiltIn(t *testing.T) {
for _, id := range []string{"openai", "anthropic", "venice", "openrouter"} {
h := GetHooks(id)
if h == nil {
t.Errorf("GetHooks(%q) returned nil", id)
}
}
}
func TestGetHooks_Unknown(t *testing.T) {
h := GetHooks("nonexistent")
if h != nil {
t.Error("GetHooks for unknown type should return nil")
}
}
// ── MergePersonaSettings Tests ──────────────
func TestMergePersonaSettings_Basic(t *testing.T) {
provider := map[string]interface{}{
"system_prompt_prefix": "Provider prompt",
"frequency_penalty": 0.3,
}
persona := map[string]interface{}{
"system_prompt_prefix": "Persona prompt",
}
merged := MergePersonaSettings("openai", provider, persona)
if merged["system_prompt_prefix"] != "Persona prompt" {
t.Errorf("persona should override provider, got %v", merged["system_prompt_prefix"])
}
if merged["frequency_penalty"] != 0.3 {
t.Errorf("provider value should be preserved, got %v", merged["frequency_penalty"])
}
}
func TestMergePersonaSettings_ProviderOnlyBlocked(t *testing.T) {
provider := map[string]interface{}{
"route": "auto",
}
persona := map[string]interface{}{
"route": "fallback", // route is ProviderOnly for openrouter
}
merged := MergePersonaSettings("openrouter", provider, persona)
if merged["route"] != "auto" {
t.Errorf("ProviderOnly field should not be overridden by persona, got %v", merged["route"])
}
}
func TestMergePersonaSettings_EmptyOverrides(t *testing.T) {
provider := map[string]interface{}{"key": "value"}
merged := MergePersonaSettings("openai", provider, nil)
if merged["key"] != "value" {
t.Error("empty overrides should return provider settings unchanged")
}
}
// ── mergeExtraBody Tests ────────────────────
func TestMergeExtraBody(t *testing.T) {
body := []byte(`{"model":"gpt-4","stream":true}`)
extra := map[string]interface{}{
"venice_parameters": map[string]interface{}{
"enable_web_search": "always",
},
}
merged, err := mergeExtraBody(body, extra)
if err != nil {
t.Fatalf("mergeExtraBody error: %v", err)
}
// Should contain both original and extra fields
s := string(merged)
if !strings.Contains(s, `"model"`) || !strings.Contains(s, `"stream"`) {
t.Error("original fields missing from merged body")
}
if !strings.Contains(s, `"venice_parameters"`) || !strings.Contains(s, `"enable_web_search"`) {
t.Error("extra fields missing from merged body")
}
}

View File

@@ -1,202 +0,0 @@
package providers
import (
"encoding/json"
"testing"
)
// ── ContentPart Tests ──────────────────────
func TestContentPart_TextOnly(t *testing.T) {
msg := Message{
Role: "user",
Content: "hello",
ContentParts: []ContentPart{
{Type: "text", Text: "hello"},
},
}
if len(msg.ContentParts) != 1 {
t.Fatalf("expected 1 part, got %d", len(msg.ContentParts))
}
if msg.ContentParts[0].Type != "text" {
t.Errorf("type = %q, want text", msg.ContentParts[0].Type)
}
}
func TestContentPart_WithImage(t *testing.T) {
msg := Message{
Role: "user",
Content: "describe this",
ContentParts: []ContentPart{
{Type: "text", Text: "describe this"},
{Type: "image_url", ImageURL: &ImageURL{URL: "data:image/png;base64,abc123", Detail: "auto"}},
},
}
if len(msg.ContentParts) != 2 {
t.Fatalf("expected 2 parts, got %d", len(msg.ContentParts))
}
if msg.ContentParts[1].ImageURL == nil {
t.Fatal("expected non-nil ImageURL")
}
if msg.ContentParts[1].ImageURL.URL != "data:image/png;base64,abc123" {
t.Errorf("URL = %q", msg.ContentParts[1].ImageURL.URL)
}
}
// ── OpenAI Multimodal Serialization ────────
func TestOpenAI_TextOnlyContent_String(t *testing.T) {
// When Content is a string, it should serialize as a JSON string
msg := openaiMessage{
Role: "user",
Content: "hello world",
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
content := raw["content"]
if s, ok := content.(string); !ok || s != "hello world" {
t.Errorf("content = %v (%T), want string 'hello world'", content, content)
}
}
func TestOpenAI_MultimodalContent_Array(t *testing.T) {
// When Content is an array, it should serialize as a JSON array
msg := openaiMessage{
Role: "user",
Content: []openaiContentPart{
{Type: "text", Text: "describe this"},
{Type: "image_url", ImageURL: &openaiImageURL{URL: "data:image/png;base64,abc", Detail: "auto"}},
},
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
content := raw["content"]
arr, ok := content.([]interface{})
if !ok {
t.Fatalf("content is %T, want array", content)
}
if len(arr) != 2 {
t.Fatalf("content has %d elements, want 2", len(arr))
}
// Verify first part is text
part0 := arr[0].(map[string]interface{})
if part0["type"] != "text" {
t.Errorf("part[0].type = %v, want text", part0["type"])
}
// Verify second part is image_url
part1 := arr[1].(map[string]interface{})
if part1["type"] != "image_url" {
t.Errorf("part[1].type = %v, want image_url", part1["type"])
}
}
// ── Anthropic Image Source ─────────────────
func TestAnthropic_ImageSourceBlock(t *testing.T) {
block := anthropicContentBlock{
Type: "image",
Source: &anthropicImageSource{
Type: "base64",
MediaType: "image/jpeg",
Data: "abc123",
},
}
data, err := json.Marshal(block)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
if raw["type"] != "image" {
t.Errorf("type = %v", raw["type"])
}
source := raw["source"].(map[string]interface{})
if source["type"] != "base64" {
t.Errorf("source.type = %v", source["type"])
}
if source["media_type"] != "image/jpeg" {
t.Errorf("source.media_type = %v", source["media_type"])
}
if source["data"] != "abc123" {
t.Errorf("source.data = %v", source["data"])
}
}
func TestAnthropic_TextBlockUnchanged(t *testing.T) {
block := anthropicContentBlock{
Type: "text",
Text: "hello",
}
data, err := json.Marshal(block)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var raw map[string]interface{}
json.Unmarshal(data, &raw)
if raw["type"] != "text" {
t.Errorf("type = %v", raw["type"])
}
if raw["text"] != "hello" {
t.Errorf("text = %v", raw["text"])
}
// Should NOT have source field
if _, ok := raw["source"]; ok {
t.Error("text block should not have source field")
}
}
// ── parseDataURI ───────────────────────────
func TestParseDataURI_Valid(t *testing.T) {
tests := []struct {
uri string
wantType string
wantData string
}{
{"data:image/jpeg;base64,/9j/4AAQ", "image/jpeg", "/9j/4AAQ"},
{"data:image/png;base64,abc123", "image/png", "abc123"},
{"data:image/webp;base64,RIFF", "image/webp", "RIFF"},
}
for _, tc := range tests {
mediaType, data := parseDataURI(tc.uri)
if mediaType != tc.wantType {
t.Errorf("parseDataURI(%q) mediaType = %q, want %q", tc.uri, mediaType, tc.wantType)
}
if data != tc.wantData {
t.Errorf("parseDataURI(%q) data = %q, want %q", tc.uri, data, tc.wantData)
}
}
}
func TestParseDataURI_Invalid(t *testing.T) {
tests := []string{
"",
"not-a-data-uri",
"data:nocomma",
"https://example.com/image.png",
}
for _, uri := range tests {
mediaType, data := parseDataURI(uri)
if mediaType != "" || data != "" {
t.Errorf("parseDataURI(%q) = (%q, %q), want empty", uri, mediaType, data)
}
}
}

View File

@@ -1,595 +0,0 @@
package providers
import (
"switchboard-core/capabilities"
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// OpenAIProvider handles any OpenAI-compatible API.
type OpenAIProvider struct{}
func (p *OpenAIProvider) ID() string { return "openai" }
// ── Chat Completion (non-streaming) ─────────
func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
req.Stream = false
body, err := p.doRequest(ctx, cfg, req)
if err != nil {
return nil, err
}
defer body.Close()
var resp openaiChatResponse
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("no choices in response")
}
// Extract content string from response (responses are always string, not array)
var contentStr string
if s, ok := resp.Choices[0].Message.Content.(string); ok {
contentStr = s
}
result := &CompletionResponse{
Content: contentStr,
Model: resp.Model,
FinishReason: resp.Choices[0].FinishReason,
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}
// Cache tokens (OpenAI, Venice report these in prompt_tokens_details)
if resp.Usage.PromptTokensDetails != nil {
result.CacheReadTokens = resp.Usage.PromptTokensDetails.CachedTokens
}
// Extract tool calls if present
if len(resp.Choices[0].Message.ToolCalls) > 0 {
for _, tc := range resp.Choices[0].Message.ToolCalls {
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
if result.FinishReason == "" {
result.FinishReason = "tool_calls"
}
}
return result, nil
}
// ── Stream Completion ───────────────────────
func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
req.Stream = true
body, err := p.doRequest(ctx, cfg, req)
if err != nil {
return nil, err
}
ch := make(chan StreamEvent, 64)
go func() {
defer close(ch)
defer body.Close()
// Accumulate tool calls across stream chunks
toolCallMap := map[int]*ToolCall{} // index → accumulated call
// Usage arrives in a separate chunk (or on the final chunk).
// OpenAI protocol order: content → finish_reason → usage → [DONE]
// The usage chunk has choices=[] so we must hold the finish event
// until usage arrives, otherwise tokens are always 0.
var streamUsage *openaiUsage
var pendingFinish *StreamEvent // deferred until usage arrives
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
// Flush any pending finish event (usage may or may not have arrived)
if pendingFinish != nil {
ch <- *pendingFinish
} else {
ch <- StreamEvent{Done: true}
}
return
}
var chunk openaiStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
// Capture usage — may arrive in a separate chunk with empty choices
if chunk.Usage != nil {
streamUsage = chunk.Usage
// If finish already arrived, attach usage and flush immediately
if pendingFinish != nil {
pendingFinish.InputTokens = streamUsage.PromptTokens
pendingFinish.OutputTokens = streamUsage.CompletionTokens
if streamUsage.PromptTokensDetails != nil {
pendingFinish.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens
}
ch <- *pendingFinish
return // Done — [DONE] will be handled by defer body.Close()
}
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
// Accumulate tool call deltas
for _, tc := range choice.Delta.ToolCalls {
idx := 0
if tc.Index != nil {
idx = *tc.Index
}
if existing, ok := toolCallMap[idx]; ok {
// Append arguments fragment
existing.Function.Arguments += tc.Function.Arguments
} else {
// First chunk for this tool — has ID and name
toolCallMap[idx] = &ToolCall{
ID: tc.ID,
Type: "function",
Function: FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
}
}
}
ev := StreamEvent{
Delta: choice.Delta.Content,
Reasoning: choice.Delta.ReasoningContent,
Model: chunk.Model,
FinishReason: choice.FinishReason,
}
if ev.FinishReason != "" {
ev.Done = true
// Attach accumulated tool calls on final event
if ev.FinishReason == "tool_calls" && len(toolCallMap) > 0 {
for _, tc := range toolCallMap {
ev.ToolCalls = append(ev.ToolCalls, *tc)
}
// Tool calls need to flush immediately — the tool loop
// needs the event to start executing tools
if streamUsage != nil {
ev.InputTokens = streamUsage.PromptTokens
ev.OutputTokens = streamUsage.CompletionTokens
if streamUsage.PromptTokensDetails != nil {
ev.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens
}
}
ch <- ev
continue
}
// Normal finish — defer until usage chunk arrives
if streamUsage != nil {
// Usage already captured (rare: same chunk or earlier)
ev.InputTokens = streamUsage.PromptTokens
ev.OutputTokens = streamUsage.CompletionTokens
if streamUsage.PromptTokensDetails != nil {
ev.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens
}
ch <- ev
continue
}
// Hold — usage chunk hasn't arrived yet
pendingFinish = &ev
continue
}
ch <- ev
}
if err := scanner.Err(); err != nil {
ch <- StreamEvent{Error: err}
} else if pendingFinish != nil {
// Scanner ended without [DONE] — flush pending finish
ch <- *pendingFinish
}
}()
return ch, nil
}
// ── List Models ─────────────────────────────
func (p *OpenAIProvider) 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 := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list models: HTTP %d: %s", resp.StatusCode, string(b))
}
var result openaiModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode models: %w", err)
}
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Heuristic inference from model ID
caps := capabilities.InferCapabilities(m.ID)
// Use context_length from API if available and we don't have it
if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength
}
out = append(out, Model{
ID: m.ID,
Type: m.Type, // passthrough from API if present (empty → "chat" at sync time)
OwnedBy: m.OwnedBy,
Capabilities: caps,
})
}
return out, nil
}
// ── Embeddings ─────────────────────────────
func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/embeddings"
oaiReq := openaiEmbeddingRequest{
Model: req.Model,
Input: req.Input,
}
body, err := json.Marshal(oaiReq)
if err != nil {
return nil, fmt.Errorf("marshal embedding request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
for k, v := range cfg.CustomHeaders {
httpReq.Header.Set(k, v)
}
resp, err := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("embedding request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embedding error: HTTP %d: %s", resp.StatusCode, string(b))
}
var oaiResp openaiEmbeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&oaiResp); err != nil {
return nil, fmt.Errorf("decode embedding response: %w", err)
}
result := &EmbeddingResponse{
Model: oaiResp.Model,
InputTokens: oaiResp.Usage.PromptTokens,
Embeddings: make([][]float64, len(oaiResp.Data)),
}
for i, d := range oaiResp.Data {
result.Embeddings[i] = d.Embedding
}
return result, nil
}
// ── HTTP Layer ──────────────────────────────
func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/chat/completions"
// Build OpenAI-format request
oaiReq := openaiChatRequest{
Model: req.Model,
Stream: req.Stream,
Messages: make([]openaiMessage, 0, len(req.Messages)),
}
if req.Stream {
oaiReq.StreamOptions = &openaiStreamOptions{IncludeUsage: true}
}
if req.MaxTokens > 0 {
oaiReq.MaxTokens = req.MaxTokens
}
if req.Temperature != nil {
oaiReq.Temperature = req.Temperature
}
if req.TopP != nil {
oaiReq.TopP = req.TopP
}
// Convert tools
for _, t := range req.Tools {
oaiReq.Tools = append(oaiReq.Tools, openaiToolDef{
Type: t.Type,
Function: openaiToolDefFunction{
Name: t.Function.Name,
Description: t.Function.Description,
Parameters: t.Function.Parameters,
},
})
}
// Convert messages — handle all roles including tool and multimodal
for _, m := range req.Messages {
oaiMsg := openaiMessage{
Role: m.Role,
}
// Build content: multimodal parts or plain string
if len(m.ContentParts) > 0 && m.Role == "user" {
// Multimodal: convert to OpenAI content array format
parts := make([]openaiContentPart, 0, len(m.ContentParts))
for _, p := range m.ContentParts {
switch p.Type {
case "image_url":
if p.ImageURL != nil {
detail := p.ImageURL.Detail
if detail == "" {
detail = "auto"
}
parts = append(parts, openaiContentPart{
Type: "image_url",
ImageURL: &openaiImageURL{
URL: p.ImageURL.URL,
Detail: detail,
},
})
}
default: // "text", "document" → text
parts = append(parts, openaiContentPart{
Type: "text",
Text: p.Text,
})
}
}
oaiMsg.Content = parts
} else {
oaiMsg.Content = m.Content
}
// Assistant messages with tool calls
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
oaiMsg.ToolCalls = append(oaiMsg.ToolCalls, openaiToolCall{
ID: tc.ID,
Type: tc.Type,
Function: openaiToolCallFunction{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
}
// Tool result messages
if m.Role == "tool" {
oaiMsg.ToolCallID = m.ToolCallID
oaiMsg.Name = m.Name
}
// Named messages: pass through for speaker attribution (v0.23.0)
// OpenAI supports "name" on any role to distinguish participants
if m.Name != "" && m.Role != "tool" {
oaiMsg.Name = m.Name
}
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
}
body, err := json.Marshal(oaiReq)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
// Merge hook-supplied extra fields into wire JSON (v0.22.1)
if len(req.ExtraBody) > 0 {
body, err = mergeExtraBody(body, req.ExtraBody)
if err != nil {
return nil, fmt.Errorf("merge extra body: %w", err)
}
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
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 := cfg.Client().Do(httpReq)
if err != nil {
return nil, fmt.Errorf("provider request: %w", err)
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b))
}
return resp.Body, nil
}
// ── OpenAI Wire Types ───────────────────────
type openaiMessage struct {
Role string `json:"role"`
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
Name string `json:"name,omitempty"` // speaker name (tool or persona attribution)
}
// openaiContentPart represents a single element in a multimodal content array.
// OpenAI format: [{"type":"text","text":"..."}, {"type":"image_url","image_url":{"url":"..."}}]
type openaiContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *openaiImageURL `json:"image_url,omitempty"`
}
type openaiImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type openaiToolCallFunction struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
type openaiToolCall struct {
Index *int `json:"index,omitempty"` // present in streaming deltas
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // "function"
Function openaiToolCallFunction `json:"function"`
}
type openaiToolDefFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
type openaiToolDef struct {
Type string `json:"type"` // "function"
Function openaiToolDefFunction `json:"function"`
}
type openaiChatRequest struct {
Model string `json:"model"`
Messages []openaiMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
StreamOptions *openaiStreamOptions `json:"stream_options,omitempty"`
Tools []openaiToolDef `json:"tools,omitempty"`
}
type openaiStreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type openaiChatResponse struct {
Model string `json:"model"`
Choices []struct {
Message openaiMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage openaiUsage `json:"usage"`
}
type openaiUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails *struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details,omitempty"`
CompletionTokensDetails *struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details,omitempty"`
}
type openaiStreamChunk struct {
Model string `json:"model"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *openaiUsage `json:"usage,omitempty"`
}
type openaiModelsResponse struct {
Data []struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by"`
Type string `json:"type,omitempty"` // Some APIs (e.g. Venice-compat) include model type
ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter
} `json:"data"`
}
// ── Embedding Wire Types ──────────────────
type openaiEmbeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type openaiEmbeddingResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
Model string `json:"model"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
} `json:"usage"`
}

View File

@@ -1,164 +0,0 @@
package providers
import (
"switchboard-core/capabilities"
"switchboard-core/models"
"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)
}
func (p *OpenRouterProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
oai := &OpenAIProvider{}
return oai.Embed(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 := cfg.Client().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)
}
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Heuristic inference from model ID
caps := capabilities.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 *models.ModelPricing
if m.Pricing.Prompt != "" {
inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64)
outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64)
if inputPerToken > 0 || outputPerToken > 0 {
pricing = &models.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
}
out = append(out, Model{
ID: m.ID,
Name: name,
OwnedBy: ownedBy,
Capabilities: caps,
Pricing: pricing,
})
}
return out, nil
}
// ── OpenRouter Wire Types ───────────────────
type orModelsResponse struct {
Data []orModel `json:"data"`
}
type orModel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ContextLength int `json:"context_length"`
Architecture orArch `json:"architecture"`
Pricing orPricing `json:"pricing"`
TopProvider orTopProvider `json:"top_provider"`
}
type orArch struct {
Modality string `json:"modality"`
Tokenizer string `json:"tokenizer"`
InstructType string `json:"instruct_type"`
}
type orPricing struct {
Prompt string `json:"prompt"` // per-token cost as string
Completion string `json:"completion"` // per-token cost as string
}
type orTopProvider struct {
MaxCompletionTokens int `json:"max_completion_tokens"`
IsModerated bool `json:"is_moderated"`
}

View File

@@ -1,154 +0,0 @@
package providers
// ProfileSchema describes the available settings for a provider type.
// Used by the admin UI to render forms and by validation on save.
type ProfileSchema struct {
// Fields lists the configurable settings in display order.
Fields []ProfileField `json:"fields"`
}
// ProfileField describes a single setting within a provider profile.
type ProfileField struct {
Key string `json:"key"` // settings JSON key
Label string `json:"label"` // human-readable name
Description string `json:"description,omitempty"` // help text
Type string `json:"type"` // "bool", "int", "string", "enum", "float"
Default interface{} `json:"default,omitempty"` // default value
EnumValues []string `json:"enum_values,omitempty"` // valid values when Type="enum"
Min *int `json:"min,omitempty"` // min for int/float
Max *int `json:"max,omitempty"` // max for int/float
DependsOn string `json:"depends_on,omitempty"` // only show when this key is truthy
ProviderOnly bool `json:"provider_only,omitempty"` // not overridable at persona level
}
// ── Built-in Profile Schemas ────────────────
// intPtr is a helper for literal pointers in struct init.
func intPtr(v int) *int { return &v }
var profileSchemas = map[string]ProfileSchema{
"openai": {
Fields: []ProfileField{
{
Key: "system_prompt_prefix",
Label: "System Prompt Prefix",
Description: "Prepended to every system prompt sent to this provider.",
Type: "string",
},
{
Key: "frequency_penalty",
Label: "Frequency Penalty",
Description: "Penalizes repeated tokens. Range: -2.0 to 2.0.",
Type: "float",
},
{
Key: "presence_penalty",
Label: "Presence Penalty",
Description: "Penalizes tokens already present. Range: -2.0 to 2.0.",
Type: "float",
},
},
},
"anthropic": {
Fields: []ProfileField{
{
Key: "system_prompt_prefix",
Label: "System Prompt Prefix",
Description: "Prepended to every system prompt sent to this provider.",
Type: "string",
},
{
Key: "extended_thinking",
Label: "Extended Thinking",
Description: "Enable Anthropic extended thinking mode. Sends thinking.type=enabled with budget_tokens.",
Type: "bool",
Default: false,
},
{
Key: "thinking_budget",
Label: "Thinking Budget (tokens)",
Description: "Max tokens for the thinking phase. Only used when extended_thinking is enabled.",
Type: "int",
Default: 10000,
Min: intPtr(1024),
Max: intPtr(128000),
DependsOn: "extended_thinking",
},
},
},
"venice": {
Fields: []ProfileField{
{
Key: "system_prompt_prefix",
Label: "System Prompt Prefix",
Description: "Prepended to every system prompt. Venice also injects its own character prompt.",
Type: "string",
},
{
Key: "enable_thinking",
Label: "Enable Thinking",
Description: "Send venice_parameters.include_venice_system_prompt=false and enable thinking output.",
Type: "bool",
Default: false,
},
{
Key: "enable_web_search",
Label: "Enable Web Search",
Description: "Allow Venice to search the web for context (venice_parameters.enable_web_search).",
Type: "bool",
Default: false,
},
{
Key: "include_venice_system_prompt",
Label: "Include Venice System Prompt",
Description: "Include Venice's built-in character system prompt.",
Type: "bool",
Default: true,
},
},
},
"openrouter": {
Fields: []ProfileField{
{
Key: "system_prompt_prefix",
Label: "System Prompt Prefix",
Description: "Prepended to every system prompt sent through OpenRouter.",
Type: "string",
},
{
Key: "route",
Label: "Routing Strategy",
Description: "How OpenRouter selects the underlying provider for a model.",
Type: "enum",
Default: "auto",
EnumValues: []string{"auto", "fallback"},
ProviderOnly: true,
},
{
Key: "require_parameters",
Label: "Require Parameters Support",
Description: "Only route to providers that support the requested parameters (tools, temperature, etc.).",
Type: "bool",
Default: true,
},
},
},
}
// GetProfileSchema returns the profile schema for a provider type.
// Falls back to the openai schema for unknown types (OpenAI-compatible).
func GetProfileSchema(providerType string) ProfileSchema {
if s, ok := profileSchemas[providerType]; ok {
return s
}
return profileSchemas["openai"]
}
// RegisterProfileSchema adds or replaces a profile schema for a provider type.
// Used for testing and future plugin support.
func RegisterProfileSchema(providerType string, schema ProfileSchema) {
profileSchemas[providerType] = schema
}

View File

@@ -1,248 +0,0 @@
package providers
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"sync"
"time"
"switchboard-core/models"
)
// ── Errors ─────────────────────────────────
var ErrNotSupported = errors.New("operation not supported by this provider")
// ── Provider Interface ──────────────────────
// Provider is the contract for LLM API adapters.
type Provider interface {
// ID returns the provider identifier (e.g. "openai", "anthropic").
ID() string
// ChatCompletion sends a non-streaming request and returns the full response.
ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error)
// StreamCompletion sends a streaming request and returns a channel of events.
// The channel is closed when the stream ends. Errors are sent as StreamEvents.
StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error)
// ListModels returns available models from this provider.
ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error)
// Embed generates embeddings for the given input texts.
// Returns ErrNotSupported if the provider has no embedding endpoint.
Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error)
}
// ── Configuration ───────────────────────────
// ProviderConfig holds credentials and endpoint for a configured provider.
// Populated from the provider_configs table at call time.
type ProviderConfig struct {
Endpoint string
APIKey string
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
Settings map[string]interface{} // Provider-specific settings from config JSONB
ProxyMode string // "system" (default), "direct", "custom"
ProxyURL string // Only used when ProxyMode == "custom"
}
// ── HTTP Client Pool ───────────────────────
//
// Provider HTTP clients are pooled by (ProxyMode, ProxyURL) tuple.
// Previously Client() created a new http.Transport per call, leaking
// idle connections and preventing TCP reuse across requests to the same
// provider. With pooling, a Venice model sync followed by a completion
// reuses the same TCP connection to api.venice.ai.
// clientPool holds shared *http.Client instances keyed by proxy config.
var clientPool sync.Map // map[string]*http.Client
// Client returns an *http.Client configured for this provider's proxy settings.
// Clients are cached and reused across calls with the same proxy configuration.
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
// custom = explicit proxy URL (http/https/socks5).
func (c ProviderConfig) Client() *http.Client {
key := c.ProxyMode + "|" + c.ProxyURL
if v, ok := clientPool.Load(key); ok {
return v.(*http.Client)
}
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
switch c.ProxyMode {
case "direct":
transport.Proxy = nil
case "custom":
if c.ProxyURL != "" {
u, err := url.Parse(c.ProxyURL)
if err != nil {
// Fall back to system rather than silently going direct —
// a bad custom URL in a mandatory-proxy environment is a
// security/audit hole if it silently bypasses.
log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err)
transport.Proxy = http.ProxyFromEnvironment
} else {
transport.Proxy = http.ProxyURL(u)
}
} else {
transport.Proxy = http.ProxyFromEnvironment
}
default: // "system" or empty
transport.Proxy = http.ProxyFromEnvironment
}
client := &http.Client{Transport: transport}
actual, _ := clientPool.LoadOrStore(key, client)
return actual.(*http.Client)
}
// ── Request / Response Types ────────────────
// Message represents a chat message in the conversation.
type Message struct {
Role string `json:"role"` // user, assistant, system, tool
Content string `json:"content"`
// Multimodal content parts (v0.12.0+). When set, providers use these
// instead of Content for the user message. Content is still set as
// a fallback and for message persistence (extracted text summary).
ContentParts []ContentPart `json:"content_parts,omitempty"`
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0)
}
// ContentPart is a single element in a multimodal message.
// Providers convert these to their native wire format.
type ContentPart struct {
Type string `json:"type"` // "text", "image_url", "document"
// Text content (type="text")
Text string `json:"text,omitempty"`
// Image content (type="image_url") — base64 data URI
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL holds image data for multimodal requests.
// URL is a data URI: "data:image/jpeg;base64,..."
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
}
// ToolCall represents a function call requested by the LLM.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function FunctionCall `json:"function"`
}
// FunctionCall is the function name and JSON arguments.
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ToolDef describes a tool available to the LLM.
type ToolDef struct {
Type string `json:"type"` // "function"
Function FunctionDef `json:"function"`
}
// FunctionDef is the schema for a tool function.
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
// CompletionRequest is the normalized request sent to any provider.
type CompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling
ExtraBody map[string]interface{} `json:"-"` // provider-specific wire fields (v0.22.1)
}
// CompletionResponse is the normalized non-streaming response.
type CompletionResponse struct {
Content string `json:"content"`
Model string `json:"model"`
FinishReason string `json:"finish_reason"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls"
}
// StreamEvent is a single chunk from a streaming response.
type StreamEvent struct {
Delta string `json:"delta,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
Done bool `json:"done,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Model string `json:"model,omitempty"`
Error error `json:"-"`
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
}
// ── Embedding Types ────────────────────────
// EmbeddingRequest is the normalized embedding request.
type EmbeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"` // batch of texts to embed
}
// EmbeddingResponse is the normalized embedding response.
type EmbeddingResponse struct {
Embeddings [][]float64 `json:"embeddings"`
Model string `json:"model"`
InputTokens int `json:"input_tokens"`
}
// Model represents an available model from a provider, with capabilities.
type Model struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"` // "chat", "embedding", "image" — from provider API
OwnedBy string `json:"owned_by,omitempty"`
Capabilities models.ModelCapabilities `json:"capabilities"`
Pricing *models.ModelPricing `json:"pricing,omitempty"`
}
// mergeExtraBody merges arbitrary key-value pairs into a marshalled JSON object.
// Used by provider doRequest methods to inject hook-supplied fields (e.g.
// venice_parameters, thinking config) into the wire-format request body.
func mergeExtraBody(body []byte, extra map[string]interface{}) ([]byte, error) {
var base map[string]interface{}
if err := json.Unmarshal(body, &base); err != nil {
return nil, err
}
for k, v := range extra {
base[k] = v
}
return json.Marshal(base)
}

View File

@@ -1,109 +0,0 @@
package providers
import (
"fmt"
"sync"
)
// registry holds all registered providers.
var (
registry = make(map[string]Provider)
mu sync.RWMutex
)
// Register adds a provider to the registry.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
registry[p.ID()] = p
}
// Get returns a provider by ID.
func Get(id string) (Provider, error) {
mu.RLock()
defer mu.RUnlock()
p, ok := registry[id]
if !ok {
return nil, fmt.Errorf("unknown provider: %s", id)
}
return p, nil
}
// List returns all registered provider IDs.
func List() []string {
mu.RLock()
defer mu.RUnlock()
ids := make([]string, 0, len(registry))
for id := range registry {
ids = append(ids, id)
}
return ids
}
// ── Provider Type Metadata ──────────────────
// ProviderTypeMeta describes a registered provider type for the admin API.
type ProviderTypeMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DefaultEndpoint string `json:"default_endpoint"`
ProfileSchema ProfileSchema `json:"profile_schema"`
}
var providerTypes = map[string]ProviderTypeMeta{}
// RegisterType adds a provider type with its metadata to the type registry.
// Called by Init() for built-in types. External types can be registered at startup.
func RegisterType(meta ProviderTypeMeta, p Provider) {
mu.Lock()
defer mu.Unlock()
registry[meta.ID] = p
providerTypes[meta.ID] = meta
}
// ListTypes returns metadata for all registered provider types.
func ListTypes() []ProviderTypeMeta {
mu.RLock()
defer mu.RUnlock()
out := make([]ProviderTypeMeta, 0, len(providerTypes))
for _, m := range providerTypes {
out = append(out, m)
}
return out
}
// Init registers all built-in providers.
func Init() {
RegisterType(ProviderTypeMeta{
ID: "openai",
Name: "OpenAI",
Description: "OpenAI and any OpenAI-compatible API (Ollama, LiteLLM, etc.)",
DefaultEndpoint: "https://api.openai.com/v1",
ProfileSchema: GetProfileSchema("openai"),
}, &OpenAIProvider{})
RegisterType(ProviderTypeMeta{
ID: "anthropic",
Name: "Anthropic",
Description: "Anthropic Messages API (Claude models)",
DefaultEndpoint: "https://api.anthropic.com",
ProfileSchema: GetProfileSchema("anthropic"),
}, &AnthropicProvider{})
RegisterType(ProviderTypeMeta{
ID: "venice",
Name: "Venice",
Description: "Venice.ai — privacy-focused, OpenAI-compatible with extensions",
DefaultEndpoint: "https://api.venice.ai/api/v1",
ProfileSchema: GetProfileSchema("venice"),
}, &VeniceProvider{})
RegisterType(ProviderTypeMeta{
ID: "openrouter",
Name: "OpenRouter",
Description: "OpenRouter — unified API for 300+ models with per-model pricing",
DefaultEndpoint: "https://openrouter.ai/api/v1",
ProfileSchema: GetProfileSchema("openrouter"),
}, &OpenRouterProvider{})
}

View File

@@ -1,168 +0,0 @@
package providers
import (
"switchboard-core/models"
"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)
}
func (p *VeniceProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
oai := &OpenAIProvider{}
return oai.Embed(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 := cfg.Client().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)
}
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
spec := m.ModelSpec
vcaps := spec.Capabilities
caps := models.ModelCapabilities{
Streaming: true,
ToolCalling: vcaps.SupportsFunctionCalling,
Vision: vcaps.SupportsVision,
Reasoning: vcaps.SupportsReasoning,
WebSearch: vcaps.SupportsWebSearch,
CodeOptimized: vcaps.OptimizedForCode,
MaxContext: spec.AvailableContextTokens,
}
// Venice doesn't report max output tokens directly.
// ResolveMaxOutput will derive from context window at resolution time.
var pricing *models.ModelPricing
if spec.Pricing.Input.USD > 0 {
pricing = &models.ModelPricing{
InputPerM: spec.Pricing.Input.USD,
OutputPerM: spec.Pricing.Output.USD,
}
}
name := spec.Name
if name == "" {
name = m.ID
}
// Normalize Venice type → our model type
// Venice returns: "text", "embedding", "image", "code"
// We normalize to: "chat", "embedding", "image"
modelType := "chat"
switch m.Type {
case "embedding":
modelType = "embedding"
case "image":
modelType = "image"
default:
modelType = "chat" // "text", "code", etc. are all chat-capable
}
out = append(out, Model{
ID: m.ID,
Name: name,
Type: modelType,
OwnedBy: "venice",
Capabilities: caps,
Pricing: pricing,
})
}
return out, 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"`
OptimizedForCode bool `json:"optimizedForCode"`
}
type venicePricing struct {
Input venicePriceUnit `json:"input"`
Output venicePriceUnit `json:"output"`
}
type venicePriceUnit struct {
USD float64 `json:"usd"`
}

View File

@@ -1,124 +0,0 @@
package retention
import (
"context"
"fmt"
"log"
"sync"
"time"
"switchboard-core/storage"
"switchboard-core/store"
)
const DefaultInterval = 1 * time.Hour
// ScannerConfig holds startup configuration.
type ScannerConfig struct {
Interval time.Duration
}
// Scanner periodically purges channels whose purge_after timestamp has passed.
// Channels with global/team provider scopes are archived with a TTL by
// DeleteChannel; this scanner performs the deferred hard-delete.
type Scanner struct {
stores store.Stores
objStore storage.ObjectStore
wg sync.WaitGroup
stopCh chan struct{}
interval time.Duration
}
// NewScanner creates a retention scanner. Call Start() to begin.
func NewScanner(stores store.Stores, objStore storage.ObjectStore, cfg ScannerConfig) *Scanner {
interval := cfg.Interval
if interval <= 0 {
interval = DefaultInterval
}
return &Scanner{
stores: stores,
objStore: objStore,
stopCh: make(chan struct{}),
interval: interval,
}
}
// Start begins the scan loop in a background goroutine.
func (sc *Scanner) Start() {
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
sc.loop()
}()
log.Printf("[retention] scanner started (interval=%s)", sc.interval)
}
// Stop signals the scanner to stop and waits for in-flight work to drain.
func (sc *Scanner) Stop() {
close(sc.stopCh)
sc.wg.Wait()
log.Printf("[retention] scanner stopped")
}
func (sc *Scanner) loop() {
ticker := time.NewTicker(sc.interval)
defer ticker.Stop()
for {
select {
case <-sc.stopCh:
return
case <-ticker.C:
sc.tick()
}
}
}
func (sc *Scanner) tick() {
ctx := context.Background()
// If TTL is 0 the feature is disabled — nothing to purge
ttl := sc.retentionTTL(ctx)
if ttl <= 0 {
return
}
ids, err := sc.stores.Channels.ListPurgeable(ctx)
if err != nil {
log.Printf("[retention] ListPurgeable error: %v", err)
return
}
if len(ids) == 0 {
return
}
log.Printf("[retention] purging %d channel(s)", len(ids))
for _, id := range ids {
// Clean up storage files first
if sc.objStore != nil {
prefix := fmt.Sprintf("files/%s", id)
if err := sc.objStore.DeletePrefix(ctx, prefix); err != nil {
log.Printf("[retention] storage cleanup for %s failed: %v", id, err)
}
}
// Hard delete (Purge verifies is_archived)
if err := sc.stores.Channels.Purge(ctx, id); err != nil {
log.Printf("[retention] purge %s failed: %v", id, err)
}
}
}
func (sc *Scanner) retentionTTL(ctx context.Context) int {
cfg, err := sc.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}

View File

@@ -1,477 +0,0 @@
package roles
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"switchboard-core/crypto"
"switchboard-core/events"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/store"
)
// ── Known Roles ────────────────────────────
const (
RoleUtility = "utility" // Internal tasks: summarization, title generation
RoleEmbedding = "embedding" // Vector embedding for knowledge bases
)
// ValidRoles lists all recognized role names.
// Note: "generation" (image/media) was removed in v0.10.2 — image gen
// will be extension-managed with its own provider config, not a role slot.
var ValidRoles = []string{RoleUtility, RoleEmbedding}
// IsValidRole returns true if the given role name is recognized.
func IsValidRole(role string) bool {
for _, r := range ValidRoles {
if r == role {
return true
}
}
return false
}
// ── Types ──────────────────────────────────
// RoleBinding pairs a provider config with a specific model.
type RoleBinding struct {
ProviderConfigID string `json:"provider_config_id"`
ModelID string `json:"model_id"`
}
// RoleConfig holds primary and fallback bindings for a role slot.
type RoleConfig struct {
Primary *RoleBinding `json:"primary"`
Fallback *RoleBinding `json:"fallback"`
}
// CompletionResult wraps a completion response with usage metadata.
type CompletionResult struct {
Content string
Model string
ProviderID string
ConfigID string
ProviderScope string
InputTokens int
OutputTokens int
CacheCreationTokens int
CacheReadTokens int
Role string
UsedFallback bool
}
// EmbeddingResult wraps an embedding response with usage metadata.
type EmbeddingResult struct {
Embeddings [][]float64
Model string
ProviderID string
ConfigID string
ProviderScope string
InputTokens int
Role string
UsedFallback bool
}
// ── Errors ─────────────────────────────────
var (
ErrRoleNotConfigured = fmt.Errorf("role not configured")
ErrNoPrimary = fmt.Errorf("no primary binding for role")
)
// ── Resolver ───────────────────────────────
// Resolver resolves named model roles to provider+model pairs and
// executes completions/embeddings against them. It handles the full
// pipeline: config lookup → key decryption → provider dispatch → fallback.
type Resolver struct {
stores store.Stores
vault *crypto.KeyResolver
bus *events.Bus // optional — nil-safe
// Fallback cooldown: suppress duplicate alerts per role
coolMu sync.Mutex
cooldown map[string]time.Time // role → last alert timestamp
}
const fallbackCooldown = 5 * time.Minute
// NewResolver creates a role resolver with access to stores and vault.
func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver {
return &Resolver{stores: s, vault: vault, cooldown: make(map[string]time.Time)}
}
// WithBus attaches an event bus for fallback alert publishing.
func (r *Resolver) WithBus(bus *events.Bus) *Resolver {
r.bus = bus
return r
}
// Complete sends a chat completion using the named role.
// Resolution: personal override → team override → global config → try primary → fallback on error.
func (r *Resolver) Complete(ctx context.Context, role string, userID string, teamID *string, messages []providers.Message) (*CompletionResult, error) {
cfg, err := r.GetConfig(ctx, role, userID, teamID)
if err != nil {
return nil, err
}
// Try primary
if cfg.Primary != nil {
result, err := r.doComplete(ctx, role, cfg.Primary, messages)
if err == nil {
return result, nil
}
log.Printf("⚠ Role %q primary failed: %v", role, err)
}
// Try fallback
if cfg.Fallback != nil {
result, err := r.doComplete(ctx, role, cfg.Fallback, messages)
if err == nil {
result.UsedFallback = true
r.onFallback(ctx, role, "completion", cfg.Primary, cfg.Fallback)
return result, nil
}
return nil, fmt.Errorf("role %q: both primary and fallback failed: %w", role, err)
}
if cfg.Primary == nil {
return nil, fmt.Errorf("%w: %s", ErrNoPrimary, role)
}
return nil, fmt.Errorf("role %q: primary failed with no fallback configured", role)
}
// Embed generates embeddings using the named role.
func (r *Resolver) Embed(ctx context.Context, role string, userID string, teamID *string, input []string) (*EmbeddingResult, error) {
cfg, err := r.GetConfig(ctx, role, userID, teamID)
if err != nil {
return nil, err
}
// Try primary
if cfg.Primary != nil {
result, err := r.doEmbed(ctx, role, cfg.Primary, input)
if err == nil {
return result, nil
}
log.Printf("⚠ Role %q primary embed failed: %v", role, err)
}
// Try fallback
if cfg.Fallback != nil {
result, err := r.doEmbed(ctx, role, cfg.Fallback, input)
if err == nil {
result.UsedFallback = true
r.onFallback(ctx, role, "embedding", cfg.Primary, cfg.Fallback)
return result, nil
}
return nil, fmt.Errorf("role %q: both primary and fallback embed failed: %w", role, err)
}
if cfg.Primary == nil {
return nil, fmt.Errorf("%w: %s", ErrNoPrimary, role)
}
return nil, fmt.Errorf("role %q: primary embed failed with no fallback", role)
}
// GetConfig returns the resolved role config for the given role name.
// Resolution order: personal override → team override → global config.
func (r *Resolver) GetConfig(ctx context.Context, role string, userID string, teamID *string) (*RoleConfig, error) {
if !IsValidRole(role) {
return nil, fmt.Errorf("unknown role: %q", role)
}
// Check personal override first (BYOK users)
if userID != "" {
personalCfg, err := r.getPersonalRoleConfig(ctx, userID, role)
if err == nil && personalCfg != nil && (personalCfg.Primary != nil || personalCfg.Fallback != nil) {
return personalCfg, nil
}
}
// Check team override
if teamID != nil && *teamID != "" {
teamCfg, err := r.getTeamRoleConfig(ctx, *teamID, role)
if err == nil && teamCfg != nil && (teamCfg.Primary != nil || teamCfg.Fallback != nil) {
return teamCfg, nil
}
}
// Fall back to global
return r.getGlobalRoleConfig(ctx, role)
}
// IsConfigured returns true if the named role has at least a primary binding.
func (r *Resolver) IsConfigured(ctx context.Context, role string) bool {
cfg, err := r.GetConfig(ctx, role, "", nil)
return err == nil && cfg != nil && cfg.Primary != nil
}
// IsPersonalOverride returns true if the user has a personal binding for the role.
func (r *Resolver) IsPersonalOverride(ctx context.Context, userID, role string) bool {
cfg, err := r.getPersonalRoleConfig(ctx, userID, role)
return err == nil && cfg != nil && cfg.Primary != nil
}
// ── Internal: Completion ───────────────────
func (r *Resolver) doComplete(ctx context.Context, role string, binding *RoleBinding, messages []providers.Message) (*CompletionResult, error) {
prov, provCfg, scope, err := r.resolveBinding(ctx, binding)
if err != nil {
return nil, err
}
resp, err := prov.ChatCompletion(ctx, provCfg, providers.CompletionRequest{
Model: binding.ModelID,
Messages: messages,
})
if err != nil {
return nil, err
}
return &CompletionResult{
Content: resp.Content,
Model: resp.Model,
ProviderID: prov.ID(),
ConfigID: binding.ProviderConfigID,
ProviderScope: scope,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
CacheCreationTokens: resp.CacheCreationTokens,
CacheReadTokens: resp.CacheReadTokens,
Role: role,
}, nil
}
// ── Internal: Embedding ────────────────────
func (r *Resolver) doEmbed(ctx context.Context, role string, binding *RoleBinding, input []string) (*EmbeddingResult, error) {
prov, provCfg, scope, err := r.resolveBinding(ctx, binding)
if err != nil {
return nil, err
}
resp, err := prov.Embed(ctx, provCfg, providers.EmbeddingRequest{
Model: binding.ModelID,
Input: input,
})
if err != nil {
return nil, err
}
return &EmbeddingResult{
Embeddings: resp.Embeddings,
Model: resp.Model,
ProviderID: prov.ID(),
ConfigID: binding.ProviderConfigID,
ProviderScope: scope,
InputTokens: resp.InputTokens,
Role: role,
}, nil
}
// ── Internal: Provider Resolution ──────────
// resolveBinding loads a provider config, decrypts the API key, and returns
// a ready-to-use Provider + ProviderConfig pair + scope.
func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (providers.Provider, providers.ProviderConfig, string, error) {
cfg, err := r.stores.Providers.GetByID(ctx, binding.ProviderConfigID)
if err != nil {
return nil, providers.ProviderConfig{}, "", fmt.Errorf("load provider config %s: %w", binding.ProviderConfigID, err)
}
prov, err := providers.Get(cfg.Provider)
if err != nil {
return nil, providers.ProviderConfig{}, "", fmt.Errorf("unknown provider %s: %w", cfg.Provider, err)
}
// Decrypt API key
apiKey := ""
if len(cfg.APIKeyEnc) > 0 && r.vault != nil {
apiKey, err = r.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
return nil, providers.ProviderConfig{}, "", fmt.Errorf("decrypt API key: %w", err)
}
} else if len(cfg.APIKeyEnc) > 0 {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
// Parse headers
headers := make(map[string]string)
if cfg.Headers != nil {
for k, v := range cfg.Headers {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
proxyURL := ""
if cfg.ProxyURL != nil {
proxyURL = *cfg.ProxyURL
}
return prov, providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: apiKey,
CustomHeaders: headers,
ProxyMode: cfg.ProxyMode,
ProxyURL: proxyURL,
}, cfg.Scope, nil
}
// ── Internal: Config Loading ───────────────
func (r *Resolver) getPersonalRoleConfig(ctx context.Context, userID, role string) (*RoleConfig, error) {
user, err := r.stores.Users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
settings := user.Settings
if settings == nil {
return nil, nil
}
rolesRaw, ok := settings["model_roles"]
if !ok {
return nil, nil
}
rolesMap, ok := rolesRaw.(map[string]interface{})
if !ok {
return nil, nil
}
roleData, ok := rolesMap[role]
if !ok {
return nil, nil
}
return parseRoleConfig(roleData)
}
func (r *Resolver) getGlobalRoleConfig(ctx context.Context, role string) (*RoleConfig, error) {
allRoles, err := r.stores.GlobalConfig.Get(ctx, "model_roles")
if err != nil {
return nil, fmt.Errorf("load global model_roles: %w", err)
}
roleData, ok := allRoles[role]
if !ok {
return nil, fmt.Errorf("%w: %s (not in global settings)", ErrRoleNotConfigured, role)
}
return parseRoleConfig(roleData)
}
func (r *Resolver) getTeamRoleConfig(ctx context.Context, teamID, role string) (*RoleConfig, error) {
team, err := r.stores.Teams.GetByID(ctx, teamID)
if err != nil {
return nil, err
}
settings := team.Settings
if settings == nil {
return nil, nil
}
rolesRaw, ok := settings["model_roles"]
if !ok {
return nil, nil
}
rolesMap, ok := rolesRaw.(map[string]interface{})
if !ok {
return nil, nil
}
roleData, ok := rolesMap[role]
if !ok {
return nil, nil
}
return parseRoleConfig(roleData)
}
// parseRoleConfig converts an interface{} (from JSONB) into a typed RoleConfig.
func parseRoleConfig(data interface{}) (*RoleConfig, error) {
b, err := json.Marshal(data)
if err != nil {
return nil, err
}
var cfg RoleConfig
if err := json.Unmarshal(b, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// ── Fallback Alerting ──────────────────────
// onFallback fires on successful fallback activation. It emits:
// - log line (always)
// - audit_log entry (always)
// - event bus "role.fallback" (once per cooldown window)
//
// The cooldown prevents flooding the admin UI with identical alerts
// when a primary is down and every request triggers the fallback.
func (r *Resolver) onFallback(ctx context.Context, role, opType string, primary, fallback *RoleBinding) {
primaryModel := ""
fallbackModel := ""
if primary != nil {
primaryModel = primary.ModelID
}
if fallback != nil {
fallbackModel = fallback.ModelID
}
log.Printf("⚠ Role %q fallback activated: %s → %s (%s)", role, primaryModel, fallbackModel, opType)
// Audit log — always written (one row per fallback fire)
_ = r.stores.Audit.Log(ctx, &models.AuditEntry{
Action: "role.fallback",
ResourceType: "role",
ResourceID: role,
Metadata: models.JSONMap{
"operation": opType,
"primary_model": primaryModel,
"fallback_model": fallbackModel,
},
})
// Bus event — cooldown-gated
if r.bus == nil {
return
}
r.coolMu.Lock()
last, exists := r.cooldown[role]
now := time.Now()
if exists && now.Sub(last) < fallbackCooldown {
r.coolMu.Unlock()
return
}
r.cooldown[role] = now
r.coolMu.Unlock()
payload, _ := json.Marshal(map[string]string{
"role": role,
"operation": opType,
"primary_model": primaryModel,
"fallback_model": fallbackModel,
"message": fmt.Sprintf("Role %q primary (%s) failed — using fallback (%s)", role, primaryModel, fallbackModel),
})
r.bus.PublishAsync(events.Event{
Label: "role.fallback",
Room: "admin", // admin-targeted
Payload: payload,
Ts: now.UnixMilli(),
})
}

View File

@@ -1,40 +0,0 @@
package routing
import (
"encoding/json"
"switchboard-core/models"
)
// FromModel converts a DB model to a routing Policy for evaluation.
func FromModel(m *models.RoutingPolicy) Policy {
p := Policy{
ID: m.ID,
Name: m.Name,
Scope: m.Scope,
TeamID: m.TeamID,
Priority: m.Priority,
Type: PolicyType(m.Type),
IsActive: m.IsActive,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
// Convert JSONMap → PolicyConfig via JSON round-trip.
// This is safe because PolicyConfig fields match the JSONB keys.
if m.Config != nil {
raw, _ := json.Marshal(m.Config)
json.Unmarshal(raw, &p.Config)
}
return p
}
// FromModels converts a slice of DB models to routing Policies.
func FromModels(ms []models.RoutingPolicy) []Policy {
out := make([]Policy, len(ms))
for i := range ms {
out[i] = FromModel(&ms[i])
}
return out
}

View File

@@ -1,378 +0,0 @@
package routing
import (
"sort"
"strings"
"switchboard-core/models"
)
// Evaluator processes routing policies to produce a ranked candidate list.
type Evaluator struct{}
// NewEvaluator creates a new routing evaluator.
func NewEvaluator() *Evaluator {
return &Evaluator{}
}
// Evaluate applies active policies to the available candidates and returns
// a ranked list. Candidates are filtered and reordered based on policy rules
// and health status. The first candidate in the result is the preferred choice.
//
// Policies are evaluated in priority order (lower priority number = first).
// The first matching policy wins — subsequent policies of the same type are skipped.
func (e *Evaluator) Evaluate(ctx *Context, policies []Policy, candidates []Candidate) ([]Candidate, *Decision) {
if len(candidates) == 0 {
return nil, nil
}
// Start with all candidates
result := make([]Candidate, len(candidates))
copy(result, candidates)
var decision Decision
decision.Candidates = len(candidates)
// Sort policies by priority
sorted := make([]Policy, len(policies))
copy(sorted, policies)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Priority < sorted[j].Priority
})
// Evaluate policies in priority order
appliedTypes := map[PolicyType]bool{}
for _, p := range sorted {
if !p.IsActive {
continue
}
// Skip if a policy of this type already matched
if appliedTypes[p.Type] {
continue
}
// Scope check: global applies to all; team only to matching teams
if p.Scope == "team" && p.TeamID != nil {
if !contains(ctx.TeamIDs, *p.TeamID) {
continue
}
}
matched := false
switch p.Type {
case PolicyProviderPrefer:
result, matched = applyProviderPrefer(result, p.Config)
case PolicyTeamRoute:
result, matched = applyTeamRoute(result, p.Config)
case PolicyCostLimit:
result, matched = applyCostLimit(result, p.Config, ctx)
case PolicyModelAlias:
result, matched = applyModelAlias(result, p.Config, ctx)
case PolicyCapabilityMatch:
result, matched = applyCapabilityMatch(result, p.Config, ctx)
}
if matched {
appliedTypes[p.Type] = true
decision.PolicyID = p.ID
decision.PolicyName = p.Name
decision.PolicyType = string(p.Type)
}
}
// Global health-aware sorting: healthy > degraded > unknown > down.
// Also skip "down" providers if any policy requested it, or by default
// when there are healthy alternatives.
skipDown := anyPolicySkipsDown(sorted)
result = sortByHealth(result, ctx.HealthStatus, skipDown)
if len(result) > 0 {
decision.Selected = result[0].ConfigID
}
return result, &decision
}
// ── Policy Implementations ──────────────────
// applyProviderPrefer reorders candidates to match the preferred provider list.
// Providers not in the preferred list are appended after, in original order.
func applyProviderPrefer(candidates []Candidate, cfg PolicyConfig) ([]Candidate, bool) {
if len(cfg.PreferredProviders) == 0 {
return candidates, false
}
// Build index: configID → position in preferred list
rank := make(map[string]int, len(cfg.PreferredProviders))
for i, id := range cfg.PreferredProviders {
rank[id] = i
}
preferred := make([]Candidate, 0, len(candidates))
rest := make([]Candidate, 0)
for _, c := range candidates {
if _, ok := rank[c.ConfigID]; ok {
c.Reason = "preferred provider"
preferred = append(preferred, c)
} else {
c.Reason = "fallback"
rest = append(rest, c)
}
}
// Sort preferred by their order in the preference list
sort.Slice(preferred, func(i, j int) bool {
return rank[preferred[i].ConfigID] < rank[preferred[j].ConfigID]
})
return append(preferred, rest...), len(preferred) > 0
}
// applyTeamRoute filters candidates to only allowed providers for this team.
func applyTeamRoute(candidates []Candidate, cfg PolicyConfig) ([]Candidate, bool) {
if len(cfg.AllowedProviders) == 0 {
return candidates, false
}
allowed := make(map[string]bool, len(cfg.AllowedProviders))
for _, id := range cfg.AllowedProviders {
allowed[id] = true
}
filtered := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
if allowed[c.ConfigID] {
c.Reason = "team-allowed provider"
filtered = append(filtered, c)
}
}
// Only apply if at least one allowed provider is available
if len(filtered) > 0 {
return filtered, true
}
return candidates, false
}
// applyCostLimit removes candidates whose estimated cost exceeds the limit.
func applyCostLimit(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) {
if cfg.MaxCostUSD == nil || *cfg.MaxCostUSD <= 0 {
return candidates, false
}
// For cost estimation we'd need request token count, which isn't
// available at routing time. Instead, filter out providers with
// pricing above the threshold per-million-tokens as a heuristic.
// A 4K-token request at $10/M = $0.04, so if max is $0.10 we'd
// filter out providers above ~$25/M. For now, just ensure pricing
// exists and tag the decision.
maxPerM := *cfg.MaxCostUSD * 1_000_000 / 4096 // rough heuristic: 4K tokens avg
filtered := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
key := c.ConfigID + ":" + c.Model
if pricing, ok := ctx.Pricing[key]; ok && pricing != nil {
if pricing.InputPerM > maxPerM {
continue // too expensive
}
}
filtered = append(filtered, c)
}
if len(filtered) > 0 && len(filtered) < len(candidates) {
return filtered, true
}
return candidates, false
}
// applyModelAlias replaces the model in matching candidates.
func applyModelAlias(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) {
if cfg.AliasFrom == "" || cfg.AliasTo == "" {
return candidates, false
}
// Only apply if the requested model matches the alias source
if !strings.EqualFold(ctx.Model, cfg.AliasFrom) {
return candidates, false
}
// If a specific provider is targeted, reorder to prefer it
if cfg.AliasProvider != "" {
for i, c := range candidates {
if c.ConfigID == cfg.AliasProvider {
candidates[i].Model = cfg.AliasTo
candidates[i].Reason = "model alias: " + cfg.AliasFrom + " → " + cfg.AliasTo
// Move to front
reordered := make([]Candidate, 0, len(candidates))
reordered = append(reordered, candidates[i])
reordered = append(reordered, candidates[:i]...)
reordered = append(reordered, candidates[i+1:]...)
return reordered, true
}
}
}
// No specific provider — just rewrite the model on all candidates
for i := range candidates {
candidates[i].Model = cfg.AliasTo
candidates[i].Reason = "model alias: " + cfg.AliasFrom + " → " + cfg.AliasTo
}
return candidates, true
}
// applyCapabilityMatch filters candidates to those whose model has all
// required capabilities. Optionally sorts remaining candidates by cheapest
// output price when PreferCheapest is set.
//
// Required capabilities map to ModelCapabilities boolean fields:
// "tool_calling", "vision", "thinking", "reasoning",
// "code_optimized", "web_search", "streaming"
func applyCapabilityMatch(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) {
if len(cfg.RequiredCaps) == 0 {
return candidates, false
}
if ctx.Capabilities == nil {
return candidates, false
}
filtered := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
key := c.ConfigID + ":" + c.Model
caps, ok := ctx.Capabilities[key]
if !ok {
continue // no capability data — skip (conservative)
}
if hasAllCaps(caps, cfg.RequiredCaps) {
c.Reason = "capability match"
filtered = append(filtered, c)
}
}
if len(filtered) == 0 {
// No candidates match — fall back to original set rather than
// returning empty (better to try than fail immediately).
return candidates, false
}
// Sort by cheapest output price if requested
if cfg.PreferCheapest && ctx.Pricing != nil {
sort.SliceStable(filtered, func(i, j int) bool {
ki := filtered[i].ConfigID + ":" + filtered[i].Model
kj := filtered[j].ConfigID + ":" + filtered[j].Model
pi, oki := ctx.Pricing[ki]
pj, okj := ctx.Pricing[kj]
if !oki || pi == nil {
return false // unknown price sorts last
}
if !okj || pj == nil {
return true
}
return pi.OutputPerM < pj.OutputPerM
})
}
return filtered, true
}
// hasAllCaps checks whether a ModelCapabilities struct has all requested
// capability flags set. Unrecognized capability names are ignored.
func hasAllCaps(caps *models.ModelCapabilities, required []string) bool {
if caps == nil {
return false
}
for _, req := range required {
switch req {
case "tool_calling":
if !caps.ToolCalling {
return false
}
case "vision":
if !caps.Vision {
return false
}
case "thinking":
if !caps.Thinking {
return false
}
case "reasoning":
if !caps.Reasoning {
return false
}
case "code_optimized":
if !caps.CodeOptimized {
return false
}
case "web_search":
if !caps.WebSearch {
return false
}
case "streaming":
if !caps.Streaming {
return false
}
// Unrecognized caps are ignored (forward-compatible)
}
}
return true
}
// ── Health Sorting ──────────────────────────
var statusOrder = map[models.ProviderStatus]int{
models.StatusHealthy: 0,
models.StatusDegraded: 1,
models.StatusUnknown: 2,
models.StatusDown: 3,
}
func sortByHealth(candidates []Candidate, health map[string]models.ProviderStatus, skipDown bool) []Candidate {
// Annotate status
for i, c := range candidates {
if s, ok := health[c.ConfigID]; ok {
candidates[i].Status = s
} else {
candidates[i].Status = models.StatusUnknown
}
}
// Filter down if requested and alternatives exist
if skipDown {
healthy := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
if c.Status != models.StatusDown {
healthy = append(healthy, c)
}
}
if len(healthy) > 0 {
candidates = healthy
}
// If all are down, keep them — better to try than fail immediately.
}
// Stable sort by health status
sort.SliceStable(candidates, func(i, j int) bool {
return statusOrder[candidates[i].Status] < statusOrder[candidates[j].Status]
})
return candidates
}
// ── Helpers ─────────────────────────────────
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func anyPolicySkipsDown(policies []Policy) bool {
for _, p := range policies {
if p.IsActive && p.Config.SkipDown {
return true
}
}
return false
}

View File

@@ -1,498 +0,0 @@
package routing
import (
"testing"
"switchboard-core/models"
)
func strPtr(s string) *string { return &s }
func f64Ptr(f float64) *float64 { return &f }
func baseCandidates() []Candidate {
return []Candidate{
{ConfigID: "cfg-openai", ProviderID: "openai", Model: "gpt-4o"},
{ConfigID: "cfg-anthropic", ProviderID: "anthropic", Model: "claude-sonnet-4-20250514"},
{ConfigID: "cfg-venice", ProviderID: "venice", Model: "llama-3.1-405b"},
}
}
func TestEvaluate_NoPolices(t *testing.T) {
e := NewEvaluator()
ctx := &Context{UserID: "u1", Model: "gpt-4o", HealthStatus: map[string]models.ProviderStatus{}}
result, decision := e.Evaluate(ctx, nil, baseCandidates())
if len(result) != 3 {
t.Fatalf("expected 3 candidates, got %d", len(result))
}
if decision.Selected != "cfg-openai" {
t.Errorf("expected first candidate selected, got %s", decision.Selected)
}
if decision.FallbackDepth != 0 {
t.Errorf("expected fallback depth 0, got %d", decision.FallbackDepth)
}
}
func TestEvaluate_ProviderPrefer(t *testing.T) {
e := NewEvaluator()
ctx := &Context{UserID: "u1", Model: "gpt-4o", HealthStatus: map[string]models.ProviderStatus{}}
policies := []Policy{
{
ID: "p1", Name: "Prefer Anthropic", Priority: 1,
Type: PolicyProviderPrefer, IsActive: true, Scope: "global",
Config: PolicyConfig{
PreferredProviders: []string{"cfg-anthropic", "cfg-venice"},
},
},
}
result, decision := e.Evaluate(ctx, policies, baseCandidates())
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected anthropic first, got %s", result[0].ConfigID)
}
if result[1].ConfigID != "cfg-venice" {
t.Errorf("expected venice second, got %s", result[1].ConfigID)
}
// openai should still be present as fallback
if result[2].ConfigID != "cfg-openai" {
t.Errorf("expected openai last, got %s", result[2].ConfigID)
}
if decision.PolicyID != "p1" {
t.Errorf("expected policy p1, got %s", decision.PolicyID)
}
}
func TestEvaluate_TeamRoute(t *testing.T) {
e := NewEvaluator()
teamID := "team-1"
ctx := &Context{
UserID: "u1",
TeamIDs: []string{"team-1"},
Model: "gpt-4o",
HealthStatus: map[string]models.ProviderStatus{},
}
policies := []Policy{
{
ID: "p1", Name: "Team uses Anthropic only", Priority: 1,
Type: PolicyTeamRoute, IsActive: true, Scope: "team", TeamID: &teamID,
Config: PolicyConfig{
AllowedProviders: []string{"cfg-anthropic"},
},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
if len(result) != 1 {
t.Fatalf("expected 1 candidate after team filter, got %d", len(result))
}
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected anthropic only, got %s", result[0].ConfigID)
}
}
func TestEvaluate_TeamRoute_WrongTeam(t *testing.T) {
e := NewEvaluator()
teamID := "team-2"
ctx := &Context{
UserID: "u1",
TeamIDs: []string{"team-1"}, // user is NOT in team-2
Model: "gpt-4o",
HealthStatus: map[string]models.ProviderStatus{},
}
policies := []Policy{
{
ID: "p1", Name: "Team 2 only", Priority: 1,
Type: PolicyTeamRoute, IsActive: true, Scope: "team", TeamID: &teamID,
Config: PolicyConfig{AllowedProviders: []string{"cfg-anthropic"}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// Policy should not apply — all 3 candidates remain
if len(result) != 3 {
t.Fatalf("expected 3 candidates (policy doesn't apply), got %d", len(result))
}
}
func TestEvaluate_ModelAlias(t *testing.T) {
e := NewEvaluator()
ctx := &Context{
UserID: "u1", Model: "fast",
HealthStatus: map[string]models.ProviderStatus{},
}
policies := []Policy{
{
ID: "p1", Name: "fast = haiku", Priority: 1,
Type: PolicyModelAlias, IsActive: true, Scope: "global",
Config: PolicyConfig{
AliasFrom: "fast",
AliasTo: "claude-haiku-4-20250414",
AliasProvider: "cfg-anthropic",
},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected anthropic first for alias, got %s", result[0].ConfigID)
}
if result[0].Model != "claude-haiku-4-20250414" {
t.Errorf("expected model rewritten to haiku, got %s", result[0].Model)
}
}
func TestEvaluate_HealthSkipDown(t *testing.T) {
e := NewEvaluator()
ctx := &Context{
UserID: "u1", Model: "gpt-4o",
HealthStatus: map[string]models.ProviderStatus{
"cfg-openai": models.StatusDown,
"cfg-anthropic": models.StatusHealthy,
"cfg-venice": models.StatusDegraded,
},
}
policies := []Policy{
{
ID: "p1", Name: "Skip down", Priority: 1,
Type: PolicyProviderPrefer, IsActive: true, Scope: "global",
Config: PolicyConfig{
PreferredProviders: []string{"cfg-openai", "cfg-anthropic"},
SkipDown: true,
},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// openai is down and should be skipped
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected anthropic first (openai is down), got %s", result[0].ConfigID)
}
// openai should not be in results at all
for _, c := range result {
if c.ConfigID == "cfg-openai" {
t.Error("down provider should be skipped")
}
}
}
func TestEvaluate_HealthSorting(t *testing.T) {
e := NewEvaluator()
ctx := &Context{
UserID: "u1", Model: "gpt-4o",
HealthStatus: map[string]models.ProviderStatus{
"cfg-openai": models.StatusDegraded,
"cfg-anthropic": models.StatusHealthy,
"cfg-venice": models.StatusUnknown,
},
}
result, _ := e.Evaluate(ctx, nil, baseCandidates())
// Should be sorted: healthy (anthropic) > degraded (openai) > unknown (venice)
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected healthy provider first, got %s", result[0].ConfigID)
}
if result[1].ConfigID != "cfg-openai" {
t.Errorf("expected degraded provider second, got %s", result[1].ConfigID)
}
}
func TestEvaluate_AllDown_KeepsCandidates(t *testing.T) {
e := NewEvaluator()
ctx := &Context{
UserID: "u1", Model: "gpt-4o",
HealthStatus: map[string]models.ProviderStatus{
"cfg-openai": models.StatusDown,
"cfg-anthropic": models.StatusDown,
"cfg-venice": models.StatusDown,
},
}
policies := []Policy{
{
ID: "p1", Priority: 1, Type: PolicyProviderPrefer, IsActive: true, Scope: "global",
Config: PolicyConfig{SkipDown: true},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// When ALL are down, should keep them rather than returning empty
if len(result) == 0 {
t.Error("should keep all candidates when all are down")
}
}
func TestEvaluate_PriorityOrder(t *testing.T) {
e := NewEvaluator()
ctx := &Context{
UserID: "u1",
TeamIDs: []string{"team-1"},
Model: "gpt-4o",
HealthStatus: map[string]models.ProviderStatus{},
}
teamID := "team-1"
policies := []Policy{
{
ID: "p-low", Name: "Low priority prefer", Priority: 10,
Type: PolicyProviderPrefer, IsActive: true, Scope: "global",
Config: PolicyConfig{PreferredProviders: []string{"cfg-venice"}},
},
{
ID: "p-high", Name: "High priority team route", Priority: 1,
Type: PolicyTeamRoute, IsActive: true, Scope: "team", TeamID: &teamID,
Config: PolicyConfig{AllowedProviders: []string{"cfg-anthropic", "cfg-openai"}},
},
}
result, decision := e.Evaluate(ctx, policies, baseCandidates())
// Team route (priority 1) should filter first, then prefer (priority 10) reorders
if decision.PolicyType != string(PolicyTeamRoute) {
t.Errorf("expected team_route to be recorded (first match), got %s", decision.PolicyType)
}
// Venice should be filtered out by team route
for _, c := range result {
if c.ConfigID == "cfg-venice" {
t.Error("venice should be filtered out by team route")
}
}
}
func TestEvaluate_EmptyCandidates(t *testing.T) {
e := NewEvaluator()
ctx := &Context{UserID: "u1"}
result, decision := e.Evaluate(ctx, nil, nil)
if result != nil {
t.Error("expected nil result for empty candidates")
}
if decision != nil {
t.Error("expected nil decision for empty candidates")
}
}
func TestEvaluate_InactivePolicy(t *testing.T) {
e := NewEvaluator()
ctx := &Context{UserID: "u1", Model: "gpt-4o", HealthStatus: map[string]models.ProviderStatus{}}
policies := []Policy{
{
ID: "p1", Priority: 1, Type: PolicyTeamRoute, IsActive: false, Scope: "global",
Config: PolicyConfig{AllowedProviders: []string{"cfg-anthropic"}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// Inactive policy should not filter
if len(result) != 3 {
t.Fatalf("inactive policy should not filter, got %d candidates", len(result))
}
}
// ── capability_match tests ──────────────────
func capContext() *Context {
return &Context{
UserID: "u1", Model: "any",
HealthStatus: map[string]models.ProviderStatus{},
Capabilities: map[string]*models.ModelCapabilities{
"cfg-openai:gpt-4o": {ToolCalling: true, Vision: true, Streaming: true},
"cfg-anthropic:claude-sonnet-4-20250514": {ToolCalling: true, Vision: true, Thinking: true, Streaming: true},
"cfg-venice:llama-3.1-405b": {ToolCalling: false, Vision: false, Streaming: true},
},
Pricing: map[string]*models.ModelPricing{
"cfg-openai:gpt-4o": {OutputPerM: 15.0},
"cfg-anthropic:claude-sonnet-4-20250514": {OutputPerM: 3.0},
"cfg-venice:llama-3.1-405b": {OutputPerM: 0.5},
},
}
}
func TestEvaluate_CapabilityMatch_ToolCalling(t *testing.T) {
e := NewEvaluator()
ctx := capContext()
policies := []Policy{
{
ID: "p1", Name: "Needs tool calling", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}},
},
}
result, decision := e.Evaluate(ctx, policies, baseCandidates())
// Venice (llama) has no tool_calling — should be filtered out
if len(result) != 2 {
t.Fatalf("expected 2 candidates with tool_calling, got %d", len(result))
}
for _, c := range result {
if c.ConfigID == "cfg-venice" {
t.Error("venice should be filtered (no tool_calling)")
}
}
if decision.PolicyID != "p1" {
t.Errorf("expected policy p1, got %s", decision.PolicyID)
}
}
func TestEvaluate_CapabilityMatch_MultipleCaps(t *testing.T) {
e := NewEvaluator()
ctx := capContext()
policies := []Policy{
{
ID: "p1", Name: "Needs thinking + tool_calling", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{RequiredCaps: []string{"thinking", "tool_calling"}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// Only anthropic has both thinking AND tool_calling
if len(result) != 1 {
t.Fatalf("expected 1 candidate with thinking+tool_calling, got %d", len(result))
}
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected anthropic, got %s", result[0].ConfigID)
}
}
func TestEvaluate_CapabilityMatch_PreferCheapest(t *testing.T) {
e := NewEvaluator()
ctx := capContext()
policies := []Policy{
{
ID: "p1", Name: "Tool calling, cheapest first", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{
RequiredCaps: []string{"tool_calling"},
PreferCheapest: true,
},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
if len(result) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(result))
}
// Anthropic ($3/M) should be before OpenAI ($15/M)
if result[0].ConfigID != "cfg-anthropic" {
t.Errorf("expected cheapest (anthropic) first, got %s", result[0].ConfigID)
}
if result[1].ConfigID != "cfg-openai" {
t.Errorf("expected openai second, got %s", result[1].ConfigID)
}
}
func TestEvaluate_CapabilityMatch_NoneMatch_FallsBack(t *testing.T) {
e := NewEvaluator()
ctx := capContext()
policies := []Policy{
{
ID: "p1", Name: "Needs web_search", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{RequiredCaps: []string{"web_search"}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// None of the candidates have web_search — should fall back to all 3
if len(result) != 3 {
t.Fatalf("expected 3 candidates (fallback), got %d", len(result))
}
}
func TestEvaluate_CapabilityMatch_StreamingOnly(t *testing.T) {
e := NewEvaluator()
ctx := capContext()
policies := []Policy{
{
ID: "p1", Name: "Streaming only", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{RequiredCaps: []string{"streaming"}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// All three have streaming — all should pass
if len(result) != 3 {
t.Fatalf("expected 3 candidates (all have streaming), got %d", len(result))
}
}
func TestEvaluate_CapabilityMatch_NoCapsData(t *testing.T) {
e := NewEvaluator()
ctx := &Context{
UserID: "u1", Model: "any",
HealthStatus: map[string]models.ProviderStatus{},
Capabilities: nil, // no capability data
}
policies := []Policy{
{
ID: "p1", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// No caps data — policy should not match, all candidates kept
if len(result) != 3 {
t.Fatalf("expected 3 candidates (no caps data), got %d", len(result))
}
}
func TestEvaluate_CapabilityMatch_EmptyRequiredCaps(t *testing.T) {
e := NewEvaluator()
ctx := capContext()
policies := []Policy{
{
ID: "p1", Priority: 1,
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
Config: PolicyConfig{RequiredCaps: []string{}},
},
}
result, _ := e.Evaluate(ctx, policies, baseCandidates())
// Empty required caps — no filtering
if len(result) != 3 {
t.Fatalf("expected 3 candidates (empty caps), got %d", len(result))
}
}
func TestHasAllCaps(t *testing.T) {
caps := &models.ModelCapabilities{
ToolCalling: true, Vision: true, Streaming: true,
}
if !hasAllCaps(caps, []string{"tool_calling"}) {
t.Error("should match tool_calling")
}
if !hasAllCaps(caps, []string{"tool_calling", "vision"}) {
t.Error("should match tool_calling+vision")
}
if hasAllCaps(caps, []string{"thinking"}) {
t.Error("should not match thinking")
}
if !hasAllCaps(caps, []string{"unknown_future_cap"}) {
t.Error("unknown caps should be ignored (forward compat)")
}
if hasAllCaps(nil, []string{"tool_calling"}) {
t.Error("nil caps should return false")
}
if !hasAllCaps(caps, nil) {
t.Error("nil required should return true")
}
if !hasAllCaps(caps, []string{}) {
t.Error("empty required should return true")
}
}

View File

@@ -1,59 +0,0 @@
package routing
import (
"fmt"
"log"
)
// ── Fallback Runner ─────────────────────────
// DispatchFunc is called by the fallback runner to attempt a request
// against a specific candidate. Returns nil on success, error on failure.
// The caller is responsible for setting up SSE headers, streaming, etc.
// This is NOT the actual dispatch — it's the "try this candidate" callback.
type DispatchFunc func(candidate Candidate) error
// RunWithFallback tries candidates in order until one succeeds.
// Returns the candidate that succeeded, the decision (updated with fallback
// depth), and any error if all candidates fail.
//
// maxRetries controls how many fallback attempts are made beyond the first.
// maxRetries=0 means only try the first candidate (no fallback).
// maxRetries=-1 means try all candidates.
func RunWithFallback(candidates []Candidate, decision *Decision, maxRetries int, dispatch DispatchFunc) (Candidate, *Decision, error) {
if len(candidates) == 0 {
return Candidate{}, decision, fmt.Errorf("no routing candidates available")
}
// Determine how many candidates to try
limit := 1 // just the first
if maxRetries < 0 {
limit = len(candidates) // try all
} else if maxRetries > 0 {
limit = 1 + maxRetries
if limit > len(candidates) {
limit = len(candidates)
}
}
var lastErr error
for i := 0; i < limit; i++ {
c := candidates[i]
err := dispatch(c)
if err == nil {
// Success
if decision != nil {
decision.Selected = c.ConfigID
decision.FallbackDepth = i
}
return c, decision, nil
}
lastErr = err
if i < limit-1 {
log.Printf("[routing] candidate %s (%s) failed: %v — trying next", c.ConfigID, c.ProviderID, err)
}
}
return Candidate{}, decision, fmt.Errorf("all %d routing candidates failed; last error: %w", limit, lastErr)
}

View File

@@ -1,118 +0,0 @@
// Package routing implements rules-based request routing for LLM providers.
// Policies are evaluated between "what model was requested" and "which
// provider config serves it," producing a ranked list of candidates.
// The fallback runner dispatches to candidates in order, retrying on failure.
package routing
import (
"time"
"switchboard-core/models"
)
// ── Policy ─────────────────────────────────
// PolicyType identifies the routing strategy.
type PolicyType string
const (
// PolicyProviderPrefer routes to a preferred provider, falling back to others.
PolicyProviderPrefer PolicyType = "provider_prefer"
// PolicyTeamRoute forces a team to use specific providers.
PolicyTeamRoute PolicyType = "team_route"
// PolicyCostLimit caps per-request cost.
PolicyCostLimit PolicyType = "cost_limit"
// PolicyModelAlias maps a model alias to a specific provider+model pair.
PolicyModelAlias PolicyType = "model_alias"
// PolicyCapabilityMatch filters to models with required capabilities,
// optionally sorted by cheapest output price. v0.29.1.
PolicyCapabilityMatch PolicyType = "capability_match"
)
// Policy is one routing rule stored in the routing_policies table.
type Policy struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Scope string `json:"scope" db:"scope"` // "global" or "team"
TeamID *string `json:"team_id,omitempty" db:"team_id"`
Priority int `json:"priority" db:"priority"` // lower = evaluated first
Type PolicyType `json:"policy_type" db:"policy_type"`
Config PolicyConfig `json:"config" db:"config"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// PolicyConfig holds the type-specific parameters for a routing policy.
// Stored as JSONB. Each PolicyType uses a subset of these fields.
type PolicyConfig struct {
// provider_prefer: ordered list of provider config IDs
PreferredProviders []string `json:"preferred_providers,omitempty"`
// team_route: required provider config IDs for this team
AllowedProviders []string `json:"allowed_providers,omitempty"`
// cost_limit: max cost per request in USD
MaxCostUSD *float64 `json:"max_cost_usd,omitempty"`
// model_alias: maps alias → provider+model
AliasFrom string `json:"alias_from,omitempty"` // e.g. "default", "fast", "smart"
AliasTo string `json:"alias_to,omitempty"` // model ID
AliasProvider string `json:"alias_provider,omitempty"` // config ID
// capability_match: filter candidates by model capabilities
RequiredCaps []string `json:"required_caps,omitempty"` // e.g. ["tool_calling", "vision"]
PreferCheapest bool `json:"prefer_cheapest,omitempty"` // sort by output_price_per_m ascending
// Shared: health requirements
SkipDown bool `json:"skip_down,omitempty"` // skip providers with status "down"
PreferHealthy bool `json:"prefer_healthy,omitempty"` // rank healthy > degraded
MaxRetries int `json:"max_retries,omitempty"` // 0 = no retry, default = 1
}
// ── Evaluation Context ─────────────────────
// Context carries the information needed to evaluate routing policies.
type Context struct {
UserID string
TeamIDs []string // teams the user belongs to
Model string // requested model ID
ConfigID string // explicitly requested provider config (may be empty)
// Health status for all active providers (keyed by config ID).
// Populated from the health accumulator at evaluation time.
HealthStatus map[string]models.ProviderStatus
// Pricing for candidate models (keyed by "configID:modelID").
// Populated lazily from the pricing store.
Pricing map[string]*models.ModelPricing
// Capabilities for candidate models (keyed by "configID:modelID").
// Populated lazily from the catalog store. Used by capability_match.
Capabilities map[string]*models.ModelCapabilities
}
// ── Candidates ─────────────────────────────
// Candidate is one possible (provider, model) pair the request could be routed to.
type Candidate struct {
ConfigID string `json:"config_id"`
ProviderID string `json:"provider_id"` // "openai", "anthropic", etc.
Model string `json:"model"`
Endpoint string `json:"-"` // internal, not exposed in API
Status models.ProviderStatus `json:"status"`
Reason string `json:"reason"` // why this candidate was selected
}
// ── Routing Decision ───────────────────────
// Decision records which candidate was selected and why.
// Stored in the usage_log for observability.
type Decision struct {
PolicyID string `json:"policy_id,omitempty"` // which policy matched
PolicyName string `json:"policy_name,omitempty"` // human-readable
PolicyType string `json:"policy_type,omitempty"`
Selected string `json:"selected"` // config ID that served the request
FallbackDepth int `json:"fallback_depth"` // 0 = first choice, 1+ = fallback
Candidates int `json:"candidates"` // total candidates evaluated
}

View File

@@ -1,386 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
func init() {
Register(&CalculatorTool{})
}
// ═══════════════════════════════════════════
// calculator
// ═══════════════════════════════════════════
type CalculatorTool struct{ BaseTool }
func (t *CalculatorTool) Definition() ToolDef {
return ToolDef{
Name: "calculator",
DisplayName: "Calculator",
Description: "Evaluate mathematical expressions with precision. Supports +, -, *, /, ^ (power), % (modulo), parentheses, and functions: sqrt, abs, ceil, floor, round, log, log2, log10, sin, cos, tan, pi, e. Use this for any arithmetic, unit conversions, percentages, or calculations instead of doing mental math.",
Category: "utilities",
Parameters: JSONSchema(map[string]interface{}{
"expression": Prop("string", "Mathematical expression to evaluate, e.g. '(100 * 1.08) ^ 5' or 'sqrt(144) + log10(1000)'"),
}, []string{"expression"}),
}
}
func (t *CalculatorTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Expression string `json:"expression"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Expression == "" {
return "", fmt.Errorf("expression is required")
}
// Normalize: replace ^ with ** for power, then rewrite for Go parser
expr := normalizeExpr(args.Expression)
result, err := evalExpr(expr)
if err != nil {
return "", fmt.Errorf("evaluation error: %w", err)
}
// Format result nicely
var display string
if result == math.Trunc(result) && !math.IsInf(result, 0) && !math.IsNaN(result) {
display = strconv.FormatFloat(result, 'f', 0, 64)
} else {
display = strconv.FormatFloat(result, 'g', 15, 64)
}
resp := map[string]interface{}{
"expression": args.Expression,
"result": result,
"display": display,
}
b, _ := json.Marshal(resp)
return string(b), nil
}
// ── Expression Normalization ────────────────
func normalizeExpr(s string) string {
// Replace common aliases
s = strings.ReplaceAll(s, "×", "*")
s = strings.ReplaceAll(s, "÷", "/")
s = strings.ReplaceAll(s, "**", "^")
return s
}
// ── Recursive Descent Evaluator ─────────────
// Supports: +, -, *, /, ^, %, unary -, parentheses, function calls, constants
type calcParser struct {
input string
pos int
}
func evalExpr(input string) (float64, error) {
p := &calcParser{input: strings.TrimSpace(input)}
result, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.input) {
return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos]))
}
return result, nil
}
func (p *calcParser) parseExpr() (float64, error) {
return p.parseAddSub()
}
func (p *calcParser) parseAddSub() (float64, error) {
left, err := p.parseMulDiv()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
if p.pos >= len(p.input) {
break
}
op := p.input[p.pos]
if op != '+' && op != '-' {
break
}
p.pos++
right, err := p.parseMulDiv()
if err != nil {
return 0, err
}
if op == '+' {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *calcParser) parseMulDiv() (float64, error) {
left, err := p.parsePower()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
if p.pos >= len(p.input) {
break
}
op := p.input[p.pos]
if op != '*' && op != '/' && op != '%' {
break
}
p.pos++
right, err := p.parsePower()
if err != nil {
return 0, err
}
switch op {
case '*':
left *= right
case '/':
if right == 0 {
return 0, fmt.Errorf("division by zero")
}
left /= right
case '%':
if right == 0 {
return 0, fmt.Errorf("modulo by zero")
}
left = math.Mod(left, right)
}
}
return left, nil
}
func (p *calcParser) parsePower() (float64, error) {
base, err := p.parseUnary()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '^' {
p.pos++
// Right-associative: 2^3^2 = 2^(3^2)
exp, err := p.parsePower()
if err != nil {
return 0, err
}
return math.Pow(base, exp), nil
}
return base, nil
}
func (p *calcParser) parseUnary() (float64, error) {
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '-' {
p.pos++
val, err := p.parseUnary()
if err != nil {
return 0, err
}
return -val, nil
}
if p.pos < len(p.input) && p.input[p.pos] == '+' {
p.pos++
return p.parseUnary()
}
return p.parsePrimary()
}
func (p *calcParser) parsePrimary() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.input) {
return 0, fmt.Errorf("unexpected end of expression")
}
// Parenthesized expression
if p.input[p.pos] == '(' {
p.pos++
val, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return 0, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return val, nil
}
// Identifier (function or constant)
if isAlpha(p.input[p.pos]) {
name := p.parseIdent()
return p.evalIdentifier(name)
}
// Number
return p.parseNumber()
}
func (p *calcParser) parseIdent() string {
start := p.pos
for p.pos < len(p.input) && (isAlpha(p.input[p.pos]) || isDigit(p.input[p.pos])) {
p.pos++
}
return strings.ToLower(p.input[start:p.pos])
}
func (p *calcParser) evalIdentifier(name string) (float64, error) {
// Constants
switch name {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
case "inf":
return math.Inf(1), nil
}
// Functions (require parenthesized argument)
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != '(' {
return 0, fmt.Errorf("unknown constant %q (did you mean %s(x)?)", name, name)
}
p.pos++ // consume '('
arg, err := p.parseExpr()
if err != nil {
return 0, err
}
// Check for second argument (e.g. pow(2, 3))
p.skipSpaces()
var arg2 float64
hasArg2 := false
if p.pos < len(p.input) && p.input[p.pos] == ',' {
p.pos++
arg2, err = p.parseExpr()
if err != nil {
return 0, err
}
hasArg2 = true
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return 0, fmt.Errorf("missing closing parenthesis for %s()", name)
}
p.pos++
switch name {
case "sqrt":
return math.Sqrt(arg), nil
case "abs":
return math.Abs(arg), nil
case "ceil":
return math.Ceil(arg), nil
case "floor":
return math.Floor(arg), nil
case "round":
return math.Round(arg), nil
case "log", "ln":
return math.Log(arg), nil
case "log2":
return math.Log2(arg), nil
case "log10":
return math.Log10(arg), nil
case "sin":
return math.Sin(arg), nil
case "cos":
return math.Cos(arg), nil
case "tan":
return math.Tan(arg), nil
case "asin":
return math.Asin(arg), nil
case "acos":
return math.Acos(arg), nil
case "atan":
return math.Atan(arg), nil
case "exp":
return math.Exp(arg), nil
case "pow":
if !hasArg2 {
return 0, fmt.Errorf("pow() requires two arguments: pow(base, exponent)")
}
return math.Pow(arg, arg2), nil
case "max":
if !hasArg2 {
return 0, fmt.Errorf("max() requires two arguments")
}
return math.Max(arg, arg2), nil
case "min":
if !hasArg2 {
return 0, fmt.Errorf("min() requires two arguments")
}
return math.Min(arg, arg2), nil
default:
return 0, fmt.Errorf("unknown function %q", name)
}
}
func (p *calcParser) parseNumber() (float64, error) {
start := p.pos
if p.pos < len(p.input) && p.input[p.pos] == '.' {
p.pos++
}
if p.pos >= len(p.input) || !isDigit(p.input[p.pos]) {
if p.pos > start {
// lone dot
return 0, fmt.Errorf("invalid number at position %d", start)
}
return 0, fmt.Errorf("expected number at position %d, got %q", p.pos, string(p.input[p.pos]))
}
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
if p.pos < len(p.input) && p.input[p.pos] == '.' {
p.pos++
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
}
// Scientific notation
if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') {
p.pos++
if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') {
p.pos++
}
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
}
val, err := strconv.ParseFloat(p.input[start:p.pos], 64)
if err != nil {
return 0, fmt.Errorf("invalid number %q", p.input[start:p.pos])
}
return val, nil
}
func (p *calcParser) skipSpaces() {
for p.pos < len(p.input) && p.input[p.pos] == ' ' {
p.pos++
}
}
func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' }
func isDigit(b byte) bool { return b >= '0' && b <= '9' }

Some files were not shown because too many files have changed in this diff Show More