2026-02-13 15:03:02 +00:00
|
|
|
// Package service orchestrates the odidere voice assistant server.
|
2026-06-19 14:31:41 +00:00
|
|
|
// It coordinates the HTTP server, LLM clients, and handles
|
2026-02-13 15:03:02 +00:00
|
|
|
// graceful shutdown.
|
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"embed"
|
2026-10-30 16:04:10 +00:00
|
|
|
"encoding/json/v2"
|
2026-02-13 15:03:02 +00:00
|
|
|
"fmt"
|
|
|
|
|
"html/template"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
|
|
|
|
"runtime/debug"
|
|
|
|
|
"strings"
|
2026-06-19 14:31:41 +00:00
|
|
|
"sync"
|
2026-02-13 15:03:02 +00:00
|
|
|
"syscall"
|
|
|
|
|
"time"
|
2026-10-30 16:04:10 +00:00
|
|
|
"uuid"
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-02-21 19:47:00 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
2026-10-30 16:04:10 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/conversation"
|
|
|
|
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
2026-06-02 12:04:02 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/job"
|
2026-02-21 19:47:00 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
|
|
|
|
"code.chimeric.al/chimerical/odidere/internal/service/templates"
|
2026-10-30 16:04:10 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/tool"
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-02 12:04:02 +00:00
|
|
|
"github.com/robfig/cron/v3"
|
2026-02-13 15:03:02 +00:00
|
|
|
)
|
|
|
|
|
|
2026-06-09 23:28:29 +00:00
|
|
|
// Context keys for request-scoped values.
|
|
|
|
|
type key string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
logKey key = "log"
|
|
|
|
|
idKey key = "id"
|
|
|
|
|
ipKey key = "ip"
|
2026-06-19 14:31:41 +00:00
|
|
|
|
|
|
|
|
modelsTimeout = 10 * time.Second
|
2026-06-09 23:28:29 +00:00
|
|
|
)
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
//go:embed all:static/*
|
|
|
|
|
var static embed.FS
|
|
|
|
|
|
|
|
|
|
// Service is the main application coordinator.
|
|
|
|
|
// It owns the HTTP server and all processing clients.
|
|
|
|
|
type Service struct {
|
2026-06-19 14:31:41 +00:00
|
|
|
cfg *config.Config
|
|
|
|
|
cron *cron.Cron
|
2026-10-30 16:04:10 +00:00
|
|
|
db *db.SQLite
|
|
|
|
|
dbr *db.Queries
|
|
|
|
|
dbw *db.Queries
|
2026-06-19 14:31:41 +00:00
|
|
|
jobs []*job.Job
|
|
|
|
|
llms map[string]*llm.Client
|
|
|
|
|
log *slog.Logger
|
|
|
|
|
allowedModels map[string]map[string]struct{}
|
|
|
|
|
mux *http.ServeMux
|
|
|
|
|
server *http.Server
|
|
|
|
|
tmpl *template.Template
|
2026-10-30 16:04:10 +00:00
|
|
|
tools *tool.Registry
|
|
|
|
|
|
|
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
2026-10-30 17:21:58 +00:00
|
|
|
wg sync.WaitGroup
|
2026-10-30 16:04:10 +00:00
|
|
|
mu sync.Mutex
|
|
|
|
|
conversations map[string]*conversation.Conversation
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New creates a Service from the provided configuration.
|
|
|
|
|
// It initializes all clients and the HTTP server.
|
2026-10-30 16:04:10 +00:00
|
|
|
func New(
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
log *slog.Logger,
|
|
|
|
|
sdb *db.SQLite,
|
|
|
|
|
) (*Service, error) {
|
|
|
|
|
if cfg == nil {
|
|
|
|
|
return nil, fmt.Errorf("config is nil")
|
|
|
|
|
}
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if log == nil {
|
|
|
|
|
return nil, fmt.Errorf("logger is nil")
|
|
|
|
|
}
|
|
|
|
|
if sdb == nil || sdb.Writer == nil || sdb.Reader == nil {
|
|
|
|
|
return nil, fmt.Errorf("database is nil")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
2026-02-13 15:03:02 +00:00
|
|
|
var svc = &Service{
|
2026-10-30 16:04:10 +00:00
|
|
|
cfg: cfg,
|
|
|
|
|
db: sdb,
|
|
|
|
|
dbr: db.New(sdb.Reader),
|
|
|
|
|
dbw: db.New(sdb.Writer),
|
|
|
|
|
log: log,
|
|
|
|
|
mux: http.NewServeMux(),
|
|
|
|
|
ctx: ctx,
|
|
|
|
|
cancel: cancel,
|
|
|
|
|
conversations: make(map[string]*conversation.Conversation),
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup tool registry.
|
2026-10-30 16:04:10 +00:00
|
|
|
registry, err := tool.NewRegistry(cfg.Tools)
|
2026-02-13 15:03:02 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("load tools: %v", err)
|
|
|
|
|
}
|
|
|
|
|
svc.tools = registry
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
// Create LLM clients for each provider.
|
|
|
|
|
svc.llms = make(map[string]*llm.Client, len(cfg.Providers))
|
|
|
|
|
for _, pc := range cfg.Providers {
|
|
|
|
|
client, err := llm.NewClient(
|
|
|
|
|
llm.Config{
|
2026-10-30 16:04:10 +00:00
|
|
|
Key: pc.Key,
|
|
|
|
|
Provider: pc.Name,
|
|
|
|
|
Timeout: pc.Timeout,
|
|
|
|
|
URL: pc.URL,
|
2026-06-19 14:31:41 +00:00
|
|
|
},
|
|
|
|
|
log,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
|
"create LLM client for provider %q: %w",
|
|
|
|
|
pc.Name, err,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
svc.llms[pc.Name] = client
|
|
|
|
|
log.Info(
|
|
|
|
|
"LLM provider registered",
|
|
|
|
|
slog.String("provider", pc.Name),
|
|
|
|
|
slog.String("url", pc.URL),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-10-30 16:04:10 +00:00
|
|
|
if svc.llms[cfg.DefaultModel.Provider] == nil {
|
|
|
|
|
return nil, fmt.Errorf("unknown default provider")
|
|
|
|
|
}
|
2026-06-19 14:31:41 +00:00
|
|
|
|
|
|
|
|
// Build model allowed list map: provider name -> set of base model IDs.
|
|
|
|
|
svc.allowedModels = make(
|
|
|
|
|
map[string]map[string]struct{}, len(cfg.Providers),
|
|
|
|
|
)
|
|
|
|
|
for _, pc := range cfg.Providers {
|
|
|
|
|
if len(pc.Models) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
allowed := make(map[string]struct{}, len(pc.Models))
|
|
|
|
|
for _, m := range pc.Models {
|
|
|
|
|
base, _, _ := strings.Cut(m, ":")
|
|
|
|
|
allowed[base] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
svc.allowedModels[pc.Name] = allowed
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 12:04:02 +00:00
|
|
|
// Convert job configs to jobs.
|
|
|
|
|
jobs := make([]*job.Job, 0, len(cfg.Jobs))
|
|
|
|
|
for _, jc := range cfg.Jobs {
|
2026-06-19 14:31:41 +00:00
|
|
|
// Resolve the provider and model for this job.
|
2026-10-30 16:04:10 +00:00
|
|
|
provider, modelID := jc.Model.Provider, jc.Model.Model
|
|
|
|
|
if provider == "" {
|
|
|
|
|
provider = cfg.DefaultModel.Provider
|
|
|
|
|
}
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
modelID = cfg.DefaultModel.Model
|
2026-06-19 14:31:41 +00:00
|
|
|
}
|
|
|
|
|
llmc := svc.llms[provider]
|
|
|
|
|
if llmc == nil {
|
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
|
"create job %q: no LLM client for provider %q",
|
|
|
|
|
jc.Name,
|
|
|
|
|
provider,
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-10-30 16:04:10 +00:00
|
|
|
jc.Model = config.ModelConfig{
|
|
|
|
|
Provider: provider,
|
|
|
|
|
Model: modelID,
|
|
|
|
|
}
|
|
|
|
|
if jc.SystemMessage == "" {
|
|
|
|
|
jc.SystemMessage = cfg.SystemMessage
|
|
|
|
|
}
|
|
|
|
|
j, err := job.New(jc, llmc, log)
|
2026-06-02 12:04:02 +00:00
|
|
|
if err != nil {
|
2026-06-19 14:31:41 +00:00
|
|
|
return nil, fmt.Errorf(
|
|
|
|
|
"create job %q: %w", jc.Name, err,
|
|
|
|
|
)
|
2026-06-02 12:04:02 +00:00
|
|
|
}
|
|
|
|
|
jobs = append(jobs, j)
|
|
|
|
|
}
|
|
|
|
|
svc.jobs = jobs
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// Parse templates.
|
|
|
|
|
tmpl, err := templates.Parse()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse templates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
svc.tmpl = tmpl
|
|
|
|
|
|
|
|
|
|
// Setup static file server.
|
|
|
|
|
staticFS, err := fs.Sub(static, "static")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("setup static fs: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register routes.
|
|
|
|
|
svc.mux.HandleFunc("GET /", svc.home)
|
|
|
|
|
svc.mux.HandleFunc("GET /status", svc.status)
|
|
|
|
|
svc.mux.Handle(
|
|
|
|
|
"GET /static/",
|
|
|
|
|
http.StripPrefix(
|
|
|
|
|
"/static/", http.FileServer(http.FS(staticFS)),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-10-30 16:04:10 +00:00
|
|
|
svc.mux.HandleFunc("GET /models", svc.models)
|
|
|
|
|
|
|
|
|
|
svc.mux.HandleFunc("GET /conversations", svc.listConversations)
|
|
|
|
|
svc.mux.HandleFunc("POST /conversations", svc.createConversation)
|
|
|
|
|
svc.mux.HandleFunc("GET /conversations/{id}", svc.getConversationHandler)
|
|
|
|
|
svc.mux.HandleFunc("DELETE /conversations/{id}", svc.deleteConversation)
|
|
|
|
|
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"POST /conversations/{id}/inference", svc.inference,
|
|
|
|
|
)
|
|
|
|
|
svc.mux.HandleFunc("POST /conversations/{id}/stop", svc.stop)
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"POST /conversations/{id}/subscribe", svc.subscribe,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"POST /conversations/{id}/messages", svc.insertMessage,
|
|
|
|
|
)
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"PUT /conversations/{id}/messages/{mid}", svc.updateMessage,
|
|
|
|
|
)
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"DELETE /conversations/{id}/messages/{mid}", svc.deleteMessage,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"POST /conversations/{id}/messages/{mid}/calls",
|
|
|
|
|
svc.addToolCall,
|
|
|
|
|
)
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"PUT /conversations/{id}/messages/{mid}/calls/{cid}",
|
|
|
|
|
svc.updateToolCall,
|
|
|
|
|
)
|
|
|
|
|
svc.mux.HandleFunc(
|
|
|
|
|
"DELETE /conversations/{id}/messages/{mid}/calls/{cid}",
|
|
|
|
|
svc.deleteToolCall,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
svc.mux.HandleFunc("GET /executions", svc.listExecutions)
|
|
|
|
|
svc.mux.HandleFunc("PUT /executions/{id}", svc.updateExecution)
|
2026-02-13 15:03:02 +00:00
|
|
|
|
|
|
|
|
svc.server = &http.Server{
|
|
|
|
|
Addr: cfg.Address,
|
|
|
|
|
Handler: svc,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return svc, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ServeHTTP implements http.Handler. It logs requests, assigns a UUID,
|
|
|
|
|
// sets context values, handles panics, and delegates to the mux.
|
|
|
|
|
func (svc *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var (
|
|
|
|
|
start = time.Now()
|
2026-10-30 16:04:10 +00:00
|
|
|
id = uuid.NewV4().String()
|
2026-02-13 15:03:02 +00:00
|
|
|
ip = func() string {
|
|
|
|
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
2026-06-19 14:31:41 +00:00
|
|
|
before, _, found := strings.Cut(ip, ",")
|
|
|
|
|
if found {
|
|
|
|
|
return strings.TrimSpace(before)
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
return ip
|
|
|
|
|
}
|
|
|
|
|
return r.RemoteAddr
|
|
|
|
|
}()
|
|
|
|
|
log = svc.log.With(slog.Group(
|
|
|
|
|
"request",
|
|
|
|
|
slog.String("id", id),
|
|
|
|
|
slog.String("ip", ip),
|
|
|
|
|
slog.String("method", r.Method),
|
|
|
|
|
slog.String("path", r.URL.Path),
|
|
|
|
|
))
|
|
|
|
|
)
|
|
|
|
|
// Log completion time.
|
|
|
|
|
defer func() {
|
|
|
|
|
log.InfoContext(
|
|
|
|
|
r.Context(),
|
|
|
|
|
"completed",
|
|
|
|
|
slog.Duration("duration", time.Since(start)),
|
|
|
|
|
)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Panic recovery.
|
|
|
|
|
defer func() {
|
|
|
|
|
if err := recover(); err != nil {
|
|
|
|
|
log.ErrorContext(
|
|
|
|
|
r.Context(),
|
|
|
|
|
"panic recovered",
|
|
|
|
|
slog.Any("error", err),
|
|
|
|
|
slog.String("stack", string(debug.Stack())),
|
|
|
|
|
)
|
|
|
|
|
http.Error(
|
|
|
|
|
w,
|
|
|
|
|
http.StatusText(
|
|
|
|
|
http.StatusInternalServerError,
|
|
|
|
|
),
|
|
|
|
|
http.StatusInternalServerError,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Enrich context with request-scoped values.
|
|
|
|
|
ctx := r.Context()
|
2026-06-09 23:28:29 +00:00
|
|
|
ctx = context.WithValue(ctx, logKey, log)
|
|
|
|
|
ctx = context.WithValue(ctx, idKey, id)
|
|
|
|
|
ctx = context.WithValue(ctx, ipKey, ip)
|
2026-02-13 15:03:02 +00:00
|
|
|
r = r.WithContext(ctx)
|
|
|
|
|
|
|
|
|
|
log.InfoContext(ctx, "handling")
|
|
|
|
|
|
2026-07-05 18:14:40 +00:00
|
|
|
// Always set request ID header.
|
|
|
|
|
w.Header().Set("X-Request-ID", id)
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// Pass the request on to the multiplexer.
|
|
|
|
|
svc.mux.ServeHTTP(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Run starts the service and blocks until shutdown.
|
|
|
|
|
// Shutdown is triggered by SIGINT or SIGTERM.
|
2026-10-30 17:21:58 +00:00
|
|
|
//
|
|
|
|
|
// The return value tells the caller when it is safe to close the
|
|
|
|
|
// database:
|
|
|
|
|
//
|
|
|
|
|
// - nil: all HTTP handlers returned, and all inference calls and
|
|
|
|
|
// scheduled jobs finished. The caller can close the database.
|
|
|
|
|
// - non-nil: shutdown did not finish within the configured
|
|
|
|
|
// ShutdownTimeout, or the HTTP server failed. Handlers,
|
|
|
|
|
// inference calls, or jobs can still use the database. Do not
|
|
|
|
|
// close the database. Exit the process instead. The database
|
|
|
|
|
// runs in WAL mode, so committed data stays safe when the
|
|
|
|
|
// process exits without a close.
|
2026-02-13 15:03:02 +00:00
|
|
|
func (svc *Service) Run(ctx context.Context) error {
|
|
|
|
|
svc.log.Info(
|
|
|
|
|
"starting odidere",
|
|
|
|
|
slog.Group(
|
|
|
|
|
"llm",
|
2026-06-19 14:31:41 +00:00
|
|
|
slog.String(
|
|
|
|
|
"default_provider",
|
|
|
|
|
svc.cfg.DefaultModel.Provider,
|
|
|
|
|
),
|
|
|
|
|
slog.String(
|
|
|
|
|
"default_model", svc.cfg.DefaultModel.Model,
|
|
|
|
|
),
|
|
|
|
|
slog.Int("providers", len(svc.cfg.Providers)),
|
2026-02-13 15:03:02 +00:00
|
|
|
),
|
|
|
|
|
slog.Group(
|
|
|
|
|
"server",
|
|
|
|
|
slog.String("address", svc.cfg.Address),
|
|
|
|
|
),
|
|
|
|
|
slog.Group(
|
|
|
|
|
"tools",
|
|
|
|
|
slog.Int("count", len(svc.tools.List())),
|
|
|
|
|
slog.Any(
|
|
|
|
|
"names",
|
|
|
|
|
strings.Join(svc.tools.List(), ","),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
slog.Group(
|
2026-06-13 18:55:16 +00:00
|
|
|
"tts_url",
|
2026-02-13 15:03:02 +00:00
|
|
|
slog.String("url", svc.cfg.TTS.URL),
|
|
|
|
|
slog.String("default_voice", svc.cfg.TTS.Voice),
|
|
|
|
|
),
|
2026-06-15 10:51:17 +00:00
|
|
|
slog.Group(
|
|
|
|
|
"stt_url",
|
|
|
|
|
slog.String("url", svc.cfg.STT.URL),
|
|
|
|
|
),
|
2026-06-02 12:04:02 +00:00
|
|
|
slog.Group(
|
|
|
|
|
"jobs",
|
|
|
|
|
slog.Int("count", svc.JobCount()),
|
|
|
|
|
),
|
2026-10-30 16:04:10 +00:00
|
|
|
slog.Any("shutdown_timeout", svc.cfg.ShutdownTimeout),
|
2026-02-13 15:03:02 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Setup signal handling for graceful shutdown.
|
|
|
|
|
ctx, cancel := signal.NotifyContext(
|
|
|
|
|
ctx,
|
|
|
|
|
os.Interrupt, syscall.SIGTERM,
|
|
|
|
|
)
|
|
|
|
|
defer cancel()
|
2026-10-30 16:04:10 +00:00
|
|
|
svc.ctx, svc.cancel = context.WithCancel(ctx)
|
|
|
|
|
defer svc.cancel()
|
2026-02-13 15:03:02 +00:00
|
|
|
|
2026-06-02 12:04:02 +00:00
|
|
|
// Register jobs with cron scheduler.
|
|
|
|
|
svc.cron = cron.New()
|
|
|
|
|
for _, j := range svc.jobs {
|
|
|
|
|
entry, err := svc.cron.AddFunc(j.Schedule(), func() {
|
2026-10-30 16:04:10 +00:00
|
|
|
title := j.Name()
|
|
|
|
|
system := j.SystemMessage()
|
|
|
|
|
_, c, err := svc.newConversation(
|
|
|
|
|
svc.ctx, j.UserId(), &title, system,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
svc.log.Error("failed to create job conversation",
|
|
|
|
|
slog.String("name", j.Name()),
|
|
|
|
|
slog.Any("error", err),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := j.Run(svc.ctx, c); err != nil {
|
2026-06-02 12:04:02 +00:00
|
|
|
svc.log.Error("job failed",
|
|
|
|
|
slog.String("name", j.Name()),
|
2026-10-30 16:04:10 +00:00
|
|
|
slog.Any("error", err),
|
2026-06-02 12:04:02 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
svc.log.Error("failed to schedule job",
|
|
|
|
|
slog.String("name", j.Name()),
|
|
|
|
|
slog.Any("error", err),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
svc.log.Info("job scheduled",
|
|
|
|
|
slog.String("name", j.Name()),
|
|
|
|
|
slog.String("schedule", j.Schedule()),
|
|
|
|
|
slog.Int("entry_id", int(entry)),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
svc.cron.Start()
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
// Start HTTP server in background.
|
|
|
|
|
var errs = make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
svc.log.Info(
|
|
|
|
|
"HTTP server listening",
|
|
|
|
|
slog.String("address", svc.cfg.Address),
|
|
|
|
|
)
|
|
|
|
|
if err := svc.server.ListenAndServe(); err != nil &&
|
|
|
|
|
err != http.ErrServerClosed {
|
|
|
|
|
errs <- err
|
|
|
|
|
}
|
|
|
|
|
close(errs)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Wait for shutdown signal or server error.
|
2026-10-30 16:04:10 +00:00
|
|
|
var runErr error
|
2026-02-13 15:03:02 +00:00
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
svc.log.Info("shutdown signal received")
|
|
|
|
|
case err := <-errs:
|
|
|
|
|
if err != nil {
|
2026-10-30 16:04:10 +00:00
|
|
|
runErr = fmt.Errorf("server error: %w", err)
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-30 16:04:10 +00:00
|
|
|
// Use one deadline for HTTP shutdown and job completion.
|
|
|
|
|
// The shutdown context must remain active after svc.ctx is canceled.
|
2026-02-13 15:03:02 +00:00
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(
|
|
|
|
|
context.Background(),
|
2026-10-30 16:04:10 +00:00
|
|
|
svc.cfg.ShutdownTimeout,
|
2026-02-13 15:03:02 +00:00
|
|
|
)
|
|
|
|
|
defer shutdownCancel()
|
|
|
|
|
|
2026-10-30 16:04:10 +00:00
|
|
|
svc.cancel()
|
2026-06-02 12:04:02 +00:00
|
|
|
|
2026-10-30 16:04:10 +00:00
|
|
|
svc.log.Info("stopping job scheduler")
|
|
|
|
|
cronCtx := svc.cron.Stop()
|
|
|
|
|
|
|
|
|
|
var shutdownErr error
|
2026-02-13 15:03:02 +00:00
|
|
|
svc.log.Info("shutting down HTTP server")
|
|
|
|
|
if err := svc.server.Shutdown(shutdownCtx); err != nil {
|
2026-10-30 16:04:10 +00:00
|
|
|
shutdownErr = fmt.Errorf("HTTP shutdown: %w", err)
|
|
|
|
|
} else {
|
2026-10-30 17:21:58 +00:00
|
|
|
// Jobs and HTTP inference can save tool results after
|
|
|
|
|
// cancellation. Wait until both return before the caller
|
|
|
|
|
// closes the database.
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
<-cronCtx.Done()
|
|
|
|
|
svc.wg.Wait()
|
|
|
|
|
close(done)
|
|
|
|
|
}()
|
2026-10-30 16:04:10 +00:00
|
|
|
select {
|
2026-10-30 17:21:58 +00:00
|
|
|
case <-done:
|
2026-10-30 16:04:10 +00:00
|
|
|
case <-shutdownCtx.Done():
|
|
|
|
|
shutdownErr = fmt.Errorf(
|
2026-10-30 17:21:58 +00:00
|
|
|
"wait for background work: %w",
|
|
|
|
|
shutdownCtx.Err(),
|
2026-10-30 16:04:10 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// On failure, main exits without closing the database because
|
|
|
|
|
// handlers or jobs may still use it.
|
|
|
|
|
if shutdownErr != nil {
|
|
|
|
|
if runErr != nil {
|
|
|
|
|
return fmt.Errorf("%w; %w", runErr, shutdownErr)
|
|
|
|
|
}
|
|
|
|
|
return shutdownErr
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
svc.log.Info("terminating")
|
2026-10-30 16:04:10 +00:00
|
|
|
return runErr
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
// newConversation saves a conversation and its initial system message.
|
|
|
|
|
// It registers the handle only after the transaction commits.
|
|
|
|
|
func (svc *Service) newConversation(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
userId string,
|
|
|
|
|
title *string,
|
|
|
|
|
system string,
|
|
|
|
|
) (*db.Conversation, *conversation.Conversation, error) {
|
|
|
|
|
msg := llm.Message{
|
|
|
|
|
Role: llm.RoleSystem,
|
|
|
|
|
ContentParts: []llm.ContentPart{{
|
|
|
|
|
Type: llm.ContentTypeText,
|
|
|
|
|
Text: system,
|
|
|
|
|
}},
|
|
|
|
|
}
|
|
|
|
|
if err := msg.Validate(); err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("invalid system message: %w", err)
|
|
|
|
|
}
|
|
|
|
|
content, err := json.Marshal(msg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("marshal system message: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tx, err := svc.db.Writer.BeginTx(ctx, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("begin tx: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
qtx := svc.dbw.WithTx(tx)
|
|
|
|
|
saved, err := qtx.InsertConversation(
|
|
|
|
|
ctx, db.InsertConversationParams{
|
|
|
|
|
Title: title,
|
|
|
|
|
UserId: userId,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("insert conversation: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := qtx.InsertMessage(ctx, db.InsertMessageParams{
|
|
|
|
|
Content: content,
|
|
|
|
|
ConversationId: saved.Id,
|
|
|
|
|
Role: db.MessageRoleSystem,
|
|
|
|
|
Sequence: 0,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("insert system message: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c, err := conversation.New(
|
|
|
|
|
conversation.Config{Id: saved.Id},
|
|
|
|
|
svc.db,
|
|
|
|
|
svc.log,
|
|
|
|
|
svc.tools,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("create conversation handle: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("commit conversation tx: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
svc.mu.Lock()
|
|
|
|
|
// A retrieval can register a handle after the commit.
|
|
|
|
|
if existing := svc.conversations[saved.Id]; existing != nil {
|
|
|
|
|
c = existing
|
|
|
|
|
} else {
|
|
|
|
|
svc.conversations[saved.Id] = c
|
|
|
|
|
}
|
|
|
|
|
svc.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
return saved, c, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getConversation checks saved state before it returns a cached handle.
|
|
|
|
|
func (svc *Service) getConversation(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
id string,
|
|
|
|
|
) (*conversation.Conversation, error) {
|
|
|
|
|
if _, err := svc.dbr.SelectConversationId(ctx, id); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("select conversation: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
svc.mu.Lock()
|
|
|
|
|
defer svc.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if c := svc.conversations[id]; c != nil {
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
c, err := conversation.New(
|
|
|
|
|
conversation.Config{Id: id},
|
|
|
|
|
svc.db,
|
|
|
|
|
svc.log,
|
|
|
|
|
svc.tools,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create conversation handle: %w", err)
|
|
|
|
|
}
|
|
|
|
|
svc.conversations[id] = c
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 12:04:02 +00:00
|
|
|
// ListJobs returns all registered jobs.
|
|
|
|
|
func (svc *Service) ListJobs() []*job.Job {
|
|
|
|
|
return svc.jobs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// JobCount returns the number of registered jobs.
|
|
|
|
|
func (svc *Service) JobCount() int {
|
|
|
|
|
return len(svc.jobs)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
func (svc *Service) home(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var (
|
|
|
|
|
ctx = r.Context()
|
2026-06-09 23:28:29 +00:00
|
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
2026-02-13 15:03:02 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if r.URL.Path != "/" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
if err := svc.tmpl.ExecuteTemplate(
|
2026-06-13 18:55:16 +00:00
|
|
|
w, "index.gohtml", struct {
|
|
|
|
|
TTSURL string
|
|
|
|
|
TTSDefaultVoice string
|
2026-06-15 10:51:17 +00:00
|
|
|
STTURL string
|
2026-06-13 18:55:16 +00:00
|
|
|
}{
|
|
|
|
|
TTSURL: svc.cfg.TTS.URL,
|
|
|
|
|
TTSDefaultVoice: svc.cfg.TTS.Voice,
|
2026-06-15 10:51:17 +00:00
|
|
|
STTURL: svc.cfg.STT.URL,
|
2026-06-13 18:55:16 +00:00
|
|
|
},
|
2026-02-13 15:03:02 +00:00
|
|
|
); err != nil {
|
|
|
|
|
log.ErrorContext(
|
|
|
|
|
ctx, "template error", slog.Any("error", err),
|
|
|
|
|
)
|
|
|
|
|
http.Error(
|
|
|
|
|
w,
|
|
|
|
|
http.StatusText(http.StatusInternalServerError),
|
|
|
|
|
http.StatusInternalServerError,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// status returns server status.
|
|
|
|
|
func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
// ModelStatus represents the availability status of a model.
|
|
|
|
|
type ModelStatus string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
// ModelStatusAvailable indicates the model is available for use.
|
|
|
|
|
ModelStatusAvailable ModelStatus = "available"
|
|
|
|
|
// ModelStatusNotFound indicates the model is configured but not found
|
|
|
|
|
// in the provider's model list.
|
|
|
|
|
ModelStatusNotFound ModelStatus = "not_found"
|
|
|
|
|
// ModelStatusProviderError indicates the provider could not be reached
|
|
|
|
|
// to verify the model.
|
|
|
|
|
ModelStatusProviderError ModelStatus = "provider_error"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-30 16:04:10 +00:00
|
|
|
// Model represents a model in the /models response.
|
2026-06-19 14:31:41 +00:00
|
|
|
type Model struct {
|
2026-06-27 12:35:11 +00:00
|
|
|
Model string `json:"model"`
|
|
|
|
|
Status ModelStatus `json:"status"`
|
|
|
|
|
ContextSize *int `json:"context_size,omitempty"`
|
2026-06-19 14:31:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 16:04:10 +00:00
|
|
|
// Models is the response format for the /models endpoint.
|
2026-06-19 14:31:41 +00:00
|
|
|
type Models struct {
|
|
|
|
|
Providers map[string][]Model `json:"providers"`
|
|
|
|
|
DefaultProvider string `json:"default_provider"`
|
|
|
|
|
DefaultModel string `json:"default_model"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// models returns available LLM models from all configured providers.
|
2026-02-13 15:03:02 +00:00
|
|
|
func (svc *Service) models(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var (
|
|
|
|
|
ctx = r.Context()
|
2026-06-09 23:28:29 +00:00
|
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
2026-06-19 14:31:41 +00:00
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
wg sync.WaitGroup
|
|
|
|
|
providers = make(map[string][]Model, len(svc.llms))
|
2026-02-13 15:03:02 +00:00
|
|
|
)
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
cfg := make(map[string]config.ProviderConfig, len(svc.cfg.Providers))
|
|
|
|
|
for _, pc := range svc.cfg.Providers {
|
|
|
|
|
cfg[pc.Name] = pc
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:31:41 +00:00
|
|
|
for name, client := range svc.llms {
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func(name string, c *llm.Client, pc config.ProviderConfig) {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
|
|
|
|
|
var result = []Model{}
|
|
|
|
|
|
|
|
|
|
fetchCtx, cancel := context.WithTimeout(
|
|
|
|
|
ctx, modelsTimeout,
|
|
|
|
|
)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
models, err := c.ListModels(fetchCtx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.WarnContext(
|
|
|
|
|
ctx,
|
|
|
|
|
"failed to list models for provider",
|
|
|
|
|
slog.String("provider", name),
|
|
|
|
|
slog.Any("error", err),
|
|
|
|
|
)
|
|
|
|
|
for _, m := range pc.Models {
|
|
|
|
|
result = append(result, Model{
|
|
|
|
|
Model: m,
|
|
|
|
|
Status: ModelStatusProviderError,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
mu.Lock()
|
|
|
|
|
providers[name] = result
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
available := make(map[string]struct{}, len(models))
|
2026-06-27 12:35:11 +00:00
|
|
|
contextSizes := make(map[string]int, len(models))
|
2026-06-19 14:31:41 +00:00
|
|
|
for _, m := range models {
|
|
|
|
|
baseID, _, _ := strings.Cut(m.ID, ":")
|
|
|
|
|
available[baseID] = struct{}{}
|
2026-06-29 16:25:42 +00:00
|
|
|
cs := m.ContextLength
|
|
|
|
|
if cs == 0 && m.Meta != nil {
|
|
|
|
|
cs = m.Meta.NCtx
|
|
|
|
|
}
|
|
|
|
|
if cs > 0 {
|
|
|
|
|
contextSizes[baseID] = cs
|
2026-06-27 12:35:11 +00:00
|
|
|
}
|
2026-06-19 14:31:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var toCheck []string
|
|
|
|
|
if len(pc.Models) > 0 {
|
|
|
|
|
toCheck = pc.Models
|
|
|
|
|
} else {
|
|
|
|
|
toCheck = make([]string, 0, len(models))
|
|
|
|
|
for _, m := range models {
|
|
|
|
|
toCheck = append(toCheck, m.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, m := range toCheck {
|
|
|
|
|
base, _, _ := strings.Cut(m, ":")
|
|
|
|
|
mi := Model{Model: m}
|
|
|
|
|
if _, ok := available[base]; ok {
|
|
|
|
|
mi.Status = ModelStatusAvailable
|
2026-06-27 12:35:11 +00:00
|
|
|
if cs, ok := contextSizes[base]; ok {
|
|
|
|
|
mi.ContextSize = &cs
|
|
|
|
|
}
|
2026-06-19 14:31:41 +00:00
|
|
|
} else {
|
|
|
|
|
mi.Status = ModelStatusNotFound
|
|
|
|
|
}
|
|
|
|
|
result = append(result, mi)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mu.Lock()
|
|
|
|
|
providers[name] = result
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
}(name, client, cfg[name])
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
2026-10-30 16:04:10 +00:00
|
|
|
if err := json.MarshalWrite(w, Models{
|
2026-06-19 14:31:41 +00:00
|
|
|
Providers: providers,
|
|
|
|
|
DefaultProvider: svc.cfg.DefaultModel.Provider,
|
|
|
|
|
DefaultModel: svc.cfg.DefaultModel.Model,
|
2026-02-13 15:03:02 +00:00
|
|
|
}); err != nil {
|
2026-06-19 14:31:41 +00:00
|
|
|
log.ErrorContext(ctx, "failed to encode models response",
|
|
|
|
|
slog.Any("error", err))
|
2026-02-13 15:03:02 +00:00
|
|
|
}
|
|
|
|
|
}
|