Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ display_name = "Local Chat"
# For a hosted API instead, e.g.:
# provider = "openai" # valid: llama-server | openai | ollama | vllm | llama-completion
# endpoint = "https://api.openai.com/v1" # any OpenAI-compatible base URL
# api_key = "sk-..." # inline (test only) — or use vault_key for the encrypted vault
# api_key = "${OPENAI_API_KEY}" # expanded from the environment; there is no vault
# (There is no dedicated "anthropic" provider — reach Anthropic via an
# OpenAI-compatible endpoint under provider = "openai".)

Expand Down Expand Up @@ -463,8 +463,9 @@ familiar-gateway` → health check:
cd ~/repos/familiar-engine && ./familiar-deploy.sh
```

(It assumes the repo is at `~/repos/familiar-engine` and a `familiar-gateway`
systemd unit exists.)
(It deploys the checkout it is run from — set `FAMILIAR_REPO` to deploy another
— and assumes a `familiar-gateway` systemd unit whose paths point at that same
checkout; the unit templates in `systemd/` use `~/repos/familiar-engine`.)

Workspace **binary** change (Go code under `familiar-workspace/`, not static):
`cd familiar-workspace && make build && sudo systemctl restart familiar-workspace`.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ See `tests/e2e/MAKE_TESTS.md` for the E2E harness details.
## Configuration

`config.example.toml` documents every gateway block. System prompts live in
`prompts/` as a tiered set (`base.md`, `tier_*.md`, `tool_policy.md`); point
`[system_prompt].dir` at it. The single config knobs you must set for a usable
`prompts/tiers/` as a tiered set (`base.md`, `tier_*.md`, `tool_policy.md`);
point `[system_prompt].prompt_dir` at that directory (the key is `prompt_dir`,
not `dir`; an unknown key is reported at boot and otherwise ignored). The single config knobs you must set for a usable
instance: `[adapter.http].listen_addr`, `[memory].local_dsn`, at least one
`[[models]]` chat model (with `"tools"` in its capabilities), `[system_prompt]`,
and the `[admin]` WebAuthn relying-party (`rp_id` / `rp_origins`).
Expand Down
41 changes: 24 additions & 17 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@
# Copy to ~/.familiar/gateway.toml and edit for your deployment.
# The gateway auto-discovers this path; override with --config <path>.
#
# Environment variable substitution is NOT supported in TOML values.
# Secrets (API keys, bot tokens) go inline; move to the vault once
# you have one set up.
# Secrets: this file holds the database password, API keys, bot tokens
# and the VAPID signing key, so keep it private (chmod 600; the gateway
# warns when others can read it). There is no vault.
#
# Environment variables: $VAR and ${VAR} are expanded in memory.local_dsn,
# model api_key and endpoint, [embedder].endpoint, the Slack tokens, the
# Brave and PirateWeather keys, the [push] VAPID keys and subject, and
# path fields (which also expand a leading ~/). Write $$ for a literal $
# (a password containing $, say); a $ that names no set variable is
# dropped, with a warning naming the field. A model that sets
# vault_key (any name) and no api_key reads its key from the
# environment variable FAMILIAR_<ID>_KEY (its id uppercased, other
# characters as _).
#
# Unknown keys are reported at boot ("... isn't a config key").
#
# ─── Minimum viable config ──────────────────────────────────────────
# To get a working single-user install, you need:
Expand Down Expand Up @@ -77,12 +89,9 @@ help_notes = "" # freeform guidance injected into the tool
# Keep this list small — it's meant for the owner and any accounts
# that must survive a database reset, not for day-to-day users.

# OWNER-MIGRATION: the legacy "owner" canonical_id was retired in
# favour of an explicit per-user identifier (e.g. "operator", "ali").
# Pre-migration deploys can rename their data with
# `internal/db/migrate.go`'s "owner_to_operator_rename" step; new
# deploys should seed themselves with the canonical_id they actually
# want. Examples below use "operator" — substitute your own.
# Seed yourself with the canonical_id you want (a per-user identifier
# such as "operator" or "ali"). Examples below use "operator" —
# substitute your own.
#
# [[identity.seed]]
# platform = "cli"
Expand All @@ -92,7 +101,7 @@ help_notes = "" # freeform guidance injected into the tool
#
# [[identity.seed]]
# platform = "openai"
# platform_id = "operator@example.com" # email used in X-User-Email header
# platform_id = "operator@example.com"
# canonical_id = "operator"
# display_name = "Operator"
#
Expand Down Expand Up @@ -124,8 +133,8 @@ history_file = "~/.familiar/cli_history"
# listener, and the gateway serves plain HTTP. Bind wider only if the
# workspace runs on another host, and firewall the port to it.
listen_addr = "127.0.0.1:8000"
# OWNER-MIGRATION removed the default_sender_id field. Requests with no
# X-User-Email header and no admin session cookie now return 401.
# Requests with no admin session cookie (or shard token, on the shard
# API) get 401.

[adapter.slack]
# Slack Socket Mode adapter. Requires app_mentions:read, chat:write,
Expand Down Expand Up @@ -363,7 +372,6 @@ pool_size = 50 # hybrid candidates fed to the reranker
window_size = 32768
output_reservation = 4096
system_prompt_ratio = 0.10 # ~10% system prompt + tool schemas
working_context_ratio = 0.05 # ~5% user profile (Layer 1)
memory_ratio = 0.12 # ~12% retrieved memories
tool_result_ratio = 0.12 # ~12% skill output
# Per-result cap: a single tool result is head+tail truncated to this
Expand Down Expand Up @@ -563,12 +571,11 @@ first_user_id = "operator"
# Legacy single-RP form. Used when no [[admin.relying_party]] blocks
# above are configured. rp_id must be the hostname the browser sees
# (no scheme, no port); rp_origins must list every scheme+host+port
# the UI is loaded from.
# the UI is loaded from — the workspace's, not this gateway's (the
# workspace serves the UI; :3000 without TLS, :8443 with it).
rp_id = "localhost"
rp_origins = [
"http://localhost:8000",
# "http://100.64.0.1:8000", # Tailscale
# "http://10.0.0.104:8000", # LAN
"http://localhost:3000",
# "https://your-host.ts.net:8443",
]

Expand Down
6 changes: 5 additions & 1 deletion familiar-deploy.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail

REPO="${FAMILIAR_REPO:-$HOME/repos/familiar}"
# The checkout this script is in (FAMILIAR_REPO overrides). It was a
# hardcoded ~/repos/familiar while the docs and units used
# ~/repos/familiar-engine, so on a box with both it could build one tree
# and restart the other.
REPO="${FAMILIAR_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
GATEWAY="$REPO/familiar-gateway"
WORKSPACE="$REPO/familiar-workspace"

Expand Down
31 changes: 12 additions & 19 deletions familiar-gateway/cmd/gateway/config.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
package main

import (
"context"
"log"
"os"
"path/filepath"
"strings"
"time"

"github.com/familiar/gateway/internal/config"
"github.com/familiar/gateway/internal/engine"
)

// loadConfig finds and loads the gateway config, falling back to defaults.
// loadConfig finds and loads the gateway config, falling back to defaults:
// $FAMILIAR_HOME/gateway.toml (~/.familiar without it), then ./gateway.toml.
func loadConfig(explicit string) (*config.Config, error) {
if explicit != "" {
return config.Load(explicit)
}

home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, ".familiar", "gateway.toml"),
filepath.Join(config.FamiliarHome(), "gateway.toml"),
"./gateway.toml",
}

Expand All @@ -35,9 +33,13 @@ func loadConfig(explicit string) (*config.Config, error) {
return config.DefaultConfig(), nil
}

// makeAPIKeyFn returns a function that resolves vault keys to API key strings.
// Resolution order: engine vault → model's api_key field → env var FAMILIAR_<ID>_KEY.
func makeAPIKeyFn(eng engine.Service, models []config.ModelConfig) func(string) string {
// makeAPIKeyFn returns a function that resolves a model's vault_key to its
// API key (the router asks only for models that set one): the model's
// api_key field, else the env var FAMILIAR_<ID>_KEY (the ID uppercased,
// anything but letters, digits and _ as _). There is no vault: the
// engine's VaultGet is unsupported, and asking it first cost a 3s
// timeout per resolution for nothing.
func makeAPIKeyFn(_ engine.Service, models []config.ModelConfig) func(string) string {
// Build a map from model ID to ModelConfig for env-var fallback.
modelsByVaultKey := make(map[string]config.ModelConfig)
modelsByID := make(map[string]config.ModelConfig)
Expand All @@ -49,21 +51,12 @@ func makeAPIKeyFn(eng engine.Service, models []config.ModelConfig) func(string)
}

return func(vaultKey string) string {
// 1. Try engine vault.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

val, found, err := eng.VaultGet(ctx, vaultKey)
if err == nil && found && val != "" {
return val
}

// 2. Try direct api_key from config.
// 1. The model's api_key from config.
if m, ok := modelsByVaultKey[vaultKey]; ok {
if m.APIKey != "" {
return m.APIKey
}
// 3. Try env var FAMILIAR_<UPPERCASE_ID>_KEY.
// 2. The env var FAMILIAR_<UPPERCASE_ID>_KEY.
envKey := "FAMILIAR_" + strings.ToUpper(strings.ReplaceAll(m.ID, "/", "_")) + "_KEY"
// Replace non-alphanumeric characters with underscores.
envKey = sanitizeEnvKey(envKey)
Expand Down
43 changes: 43 additions & 0 deletions familiar-gateway/cmd/gateway/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/familiar/gateway/internal/config"
)

// A model's key comes from its api_key or FAMILIAR_<ID>_KEY. The
// resolver asked an engine vault first, which is unsupported (a 3s
// timeout per resolution); it no longer touches the engine at all.
func TestAPIKeyFn_NoVault(t *testing.T) {
t.Setenv("FAMILIAR_OPENAI_GPT_KEY", "from-env")
fn := makeAPIKeyFn(nil, []config.ModelConfig{
{ID: "openai/gpt", VaultKey: "gpt"},
{ID: "inline", VaultKey: "inline", APIKey: "from-config"},
})
if got := fn("gpt"); got != "from-env" {
t.Errorf("env key = %q", got)
}
if got := fn("inline"); got != "from-config" {
t.Errorf("inline key = %q", got)
}
}

// The config is looked for in $FAMILIAR_HOME (the units set it).
func TestLoadConfig_FamiliarHome(t *testing.T) {
dir := t.TempDir()
t.Setenv("FAMILIAR_HOME", dir)
body := "[[models]]\nid = \"from-home\"\nprovider = \"llama-server\"\nendpoint = \"http://m\"\nchat = true\n"
if err := os.WriteFile(filepath.Join(dir, "gateway.toml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig("")
if err != nil {
t.Fatal(err)
}
if len(cfg.Models) == 0 || cfg.Models[0].ID != "from-home" {
t.Errorf("loaded %+v, want the config in FAMILIAR_HOME", cfg.Models)
}
}
28 changes: 28 additions & 0 deletions familiar-gateway/cmd/gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -877,13 +877,41 @@ func main() {
} else {
log.Printf("[gateway] shutdown signal received — draining")
}
// Cancel the root context on every exit path, before anything is
// closed: on an adapter error it was cancelled only by the deferred
// stop, which runs after the deferred pool.Close, so background
// loops ran against a closed pool.
stop()
// The other adapters unwind on the cancel, each draining its own
// in-flight requests; wait for them rather than closing the pool
// under them.
for i := 1; i < adapterCount; i++ {
select {
case err := <-adapterErrs:
if err != nil && !errors.Is(err, context.Canceled) {
log.Printf("[gateway] adapter error while stopping: %v", err)
}
case <-time.After(shutdownWait):
log.Printf("[gateway] %d adapter(s) still stopping after %v — closing anyway", adapterCount-i, shutdownWait)
i = adapterCount
}
}
// Post-turn work (fact extraction, summaries) runs detached from
// any request; let it finish before the pool closes.
if pl != nil && !pl.Drain(shutdownWait) {
log.Printf("[gateway] post-turn work still running after %v — closing anyway", shutdownWait)
}
// Explicitly stop the consolidation cycle here, before the deferred
// pool.Close runs, so a mid-pass sleep query can't race the pool
// closing. Idempotent: the deferred eng.Close() calls Stop() again
// (a sync.Once no-op).
inProcMem.Close()
}

// shutdownWait bounds each wait of a shutdown: for the remaining
// adapters to stop, and for post-turn work to finish.
const shutdownWait = 15 * time.Second

// openDBWithRetry opens the pool, retrying with backoff until it
// connects or wait elapses. On a reboot the database often isn't up yet
// when the gateway starts (the Postgres container comes up after it),
Expand Down
1 change: 1 addition & 0 deletions familiar-gateway/cmd/gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ enabled = false

[[models]]
id = "test/dummy"
provider = "llama-server"
endpoint = "http://127.0.0.1:1"
role = "small"
`, filepath.Join(dir, "skills"))
Expand Down
36 changes: 27 additions & 9 deletions familiar-gateway/internal/adapter/native/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,12 @@ func listenAddr(configured string) string {
return configured
}

// shutdownGrace is how long a shutdown waits for in-flight requests.
const shutdownGrace = 5 * time.Second

// Run starts the HTTP server. Blocks until ctx is cancelled or the
// server fails. Graceful shutdown waits up to 5s for in-flight
// requests.
// server fails; on cancellation it returns once in-flight requests have
// finished, or after shutdownGrace.
func (a *Adapter) Run(ctx context.Context) error {

addr := listenAddr(a.cfg.ListenAddr)
Expand All @@ -371,19 +374,34 @@ func (a *Adapter) Run(ctx context.Context) error {
IdleTimeout: 60 * time.Second,
}

ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("HTTP server error: %w", err)
}
log.Printf("[http] Listening on %s", addr)
log.Printf("[http] Native chat at POST /api/chat; memlog at /events/{session_id}")
return serve(ctx, server, ln, shutdownGrace)
}

// serve runs server on ln until ctx is cancelled, then shuts it down and
// returns once the in-flight requests finish (or grace runs out).
// Serve returns as soon as Shutdown begins, not when it's done, so
// returning then let the process exit mid-request (a wiki save cut off
// in its transaction) while the drain was still going.
func serve(ctx context.Context, server *http.Server, ln net.Listener, grace time.Duration) error {
drained := make(chan error, 1)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
defer cancel()
server.Shutdown(shutdownCtx)
drained <- server.Shutdown(shutdownCtx)
}()

log.Printf("[http] Listening on %s", addr)
log.Printf("[http] Native chat at POST /api/chat; memlog at /events/{session_id}")

if err := server.ListenAndServe(); err != http.ErrServerClosed {
if err := server.Serve(ln); err != http.ErrServerClosed {
return fmt.Errorf("HTTP server error: %w", err)
}
if err := <-drained; err != nil {
log.Printf("[http] shutdown: in-flight requests still running after %v were cut (%v)", grace, err)
}
return nil
}

Expand Down
46 changes: 46 additions & 0 deletions familiar-gateway/internal/adapter/native/shutdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package native

import (
"context"
"net"
"net/http"
"sync/atomic"
"testing"
"time"
)

// A shutdown returns once the in-flight request has finished. Serve
// returns as soon as Shutdown begins, and Run returned then, so the
// process exited mid-request while the drain was still going.
func TestServe_WaitsForInFlightRequests(t *testing.T) {
var finished atomic.Bool
started := make(chan struct{})
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
time.Sleep(300 * time.Millisecond)
finished.Store(true)
w.WriteHeader(http.StatusNoContent)
})}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- serve(ctx, srv, ln, 5*time.Second) }()

go func() { _, _ = http.Get("http://" + ln.Addr().String() + "/") }()
<-started
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("serve: %v", err)
}
if !finished.Load() {
t.Error("serve returned before the in-flight request finished")
}
case <-time.After(5 * time.Second):
t.Fatal("serve never returned")
}
}
Loading
Loading