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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 0–99; 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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", ¬es); 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", ¬eLinks); 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", ¬ifPrefs); 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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user