// chatd is the LLM chat dispatcher service (Phase 4 — small-model // pipeline tier abstraction). Routes POST /chat to the right // provider based on the model-name prefix or :cloud suffix: // // ollama/ → local Ollama (no auth) // ollama_cloud/ → Ollama Cloud (Bearer auth) // :cloud → Ollama Cloud (suffix variant) // openrouter// → OpenRouter (Bearer auth) // opencode/ → OpenCode unified Zen+Go (Bearer auth) // kimi/ → Kimi For Coding (Bearer auth) // bare names → local Ollama (default) // // Provider keys come from env vars (or /etc/lakehouse/.env // fallback files). Providers with empty keys stay unregistered, so // requests for them 404 cleanly instead of 503-ing at call time. // // Routes: // // POST /chat — dispatch a chat request to the resolved provider // GET /providers — list registered providers (telemetry / health) // GET /health — readiness (always 200 — sub-providers are // independently checked via /providers) package main import ( "encoding/json" "flag" "errors" "log/slog" "net/http" "os" "time" "github.com/go-chi/chi/v5" "git.agentview.dev/profit/golangLAKEHOUSE/internal/chat" "git.agentview.dev/profit/golangLAKEHOUSE/internal/shared" ) const maxRequestBytes = 4 << 20 // 4 MiB cap on /chat bodies func main() { configPath := flag.String("config", "lakehouse.toml", "path to TOML config") flag.Parse() cfg, err := shared.LoadConfig(*configPath) if err != nil { slog.Error("config", "err", err) os.Exit(1) } timeout := time.Duration(cfg.Chatd.TimeoutSecs) * time.Second registry := chat.BuildRegistry(chat.BuilderInput{ OllamaURL: cfg.Chatd.OllamaURL, OllamaCloudKey: chat.ResolveKey(cfg.Chatd.OllamaCloudKeyEnv, cfg.Chatd.OllamaCloudKeyFile), OpenRouterKey: chat.ResolveKey(cfg.Chatd.OpenRouterKeyEnv, cfg.Chatd.OpenRouterKeyFile), OpenCodeKey: chat.ResolveKey(cfg.Chatd.OpenCodeKeyEnv, cfg.Chatd.OpenCodeKeyFile), KimiKey: chat.ResolveKey(cfg.Chatd.KimiKeyEnv, cfg.Chatd.KimiKeyFile), Timeout: timeout, }) h := &handlers{registry: registry} if err := shared.Run("chatd", cfg.Chatd.Bind, h.register, cfg.Auth); err != nil { slog.Error("server", "err", err) os.Exit(1) } } type handlers struct { registry *chat.Registry } func (h *handlers) register(r chi.Router) { // Routes mirror what the gateway proxies: /v1/chat → /chat (POST) // and /v1/chat/providers → /chat/providers (GET). Keeping providers // under /chat/ avoids a separate /providers root route that would // need its own gateway proxy entry. r.Post("/chat", h.handleChat) r.Get("/chat/providers", h.handleProviders) } func (h *handlers) handleChat(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes) defer r.Body.Close() var req chat.Request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) return } if req.Model == "" || len(req.Messages) == 0 { http.Error(w, "model and messages are required", http.StatusBadRequest) return } resp, err := h.registry.Chat(r.Context(), req) if err != nil { writeChatError(w, err) return } writeJSON(w, http.StatusOK, resp) } // handleProviders lists registered providers + per-provider Available() // status. Phase 4 scrum fix B-2: looks up by name directly, skipping // the prior synthetic-model-name route through Resolve. The endpoint // reports registry membership, not routing rules — the latter is // covered by /v1/chat itself. func (h *handlers) handleProviders(w http.ResponseWriter, _ *http.Request) { names := h.registry.Names() statuses := make(map[string]bool, len(names)) for _, n := range names { statuses[n] = h.registry.Available(n) } writeJSON(w, http.StatusOK, map[string]any{ "providers": statuses, }) } // writeChatError maps chat sentinel errors to HTTP status codes. // Unknown errors map to 500. func writeChatError(w http.ResponseWriter, err error) { switch { case errors.Is(err, chat.ErrProviderNotFound): http.Error(w, err.Error(), http.StatusNotFound) case errors.Is(err, chat.ErrProviderDisabled): http.Error(w, err.Error(), http.StatusServiceUnavailable) case errors.Is(err, chat.ErrUpstream): http.Error(w, err.Error(), http.StatusBadGateway) case errors.Is(err, chat.ErrTimeout): http.Error(w, err.Error(), http.StatusGatewayTimeout) default: slog.Error("chat", "err", err) http.Error(w, "internal", http.StatusInternalServerError) } } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(body); err != nil { slog.Error("encode", "err", err) } }