# Infrastruktur: AI Gateway (LiteLLM)
**Filsti:** `docs/infra/ai_gateway.md`

## 1. Konsept
Synops bruker en sentralisert AI Gateway (LiteLLM) som eneste kontaktpunkt for alle AI-kall i systemet. All kode — Rust-workers, SvelteKit server-side — snakker med `http://ai-gateway:4000/v1`. Aldri direkte til leverandør-APIer.

Fordeler:
* **BYOK (Bring Your Own Key):** Direkte API-nøkler til Anthropic, Google, xAI — ingen markup
* **OpenRouter som fallback:** Tilgang til alle modeller vi ikke har direkte nøkler til, og sikkerhetsventil ved nedetid
* **Kostnadskontroll:** Rutineoppgaver rutes til gratisnivå (Gemini), dyre modeller kun når det trengs
* **Sentralisert logging:** Token-bruk per funksjon (Podcastfabrikken, Editor AI-behandling, Live-assistent) på ett sted
* **Redundans:** Automatisk failover mellom leverandører — redaksjonen merker ikke nedetid

## 2. Leverandører og bruksmønster

| Leverandør | Nøkkeltype | Primært bruksområde |
|---|---|---|
| Google Gemini | BYOK (gratisnivå) | Rutineoppgaver: transkripsjonsvasking, research-oppsummering, metadata-uttrekk |
| Anthropic (Claude) | BYOK | Oppgaver som krever høy resonneringsevne: live-assistent faktoid-vurdering, kompleks analyse |
| xAI (Grok) | BYOK | Alternativ for analyse, sanntidssøk (når tilgjengelig) |
| OpenRouter | BYOK | Fallback for alle modeller, sikkerhetsventil ved leverandør-nedetid |

**Merk:** Kvaliteten på norsk tekst varierer mellom modeller. Test alltid med norsk innhold før en modell tildeles en produksjonsoppgave.

## 3. Modellruting

### 3.1 Arkitekturprinsipp: PG eier config, LiteLLM er stateløs

PostgreSQL er single source of truth for all modellkonfigurasjon. LiteLLM er en stateløs proxy som får generert `config.yaml` fra PG-data. Dette gir:

* **Ingen avhengighet til LiteLLM sitt admin API** — de endrer API mellom versjoner
* **All konfig i samme backup/migrasjon** som resten av systemet
* **Enkel bytte** — hvis LiteLLM erstattes, er all konfig intakt i PG
* **Admin-UI i SvelteKit** — gjenbruker eksisterende `/admin/`-mønster

### 3.2 Datamodell

```sql
-- Globale modellaliaser (server-nivå)
CREATE TABLE ai_model_aliases (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alias         TEXT NOT NULL,           -- 'sidelinja/rutine', 'sidelinja/resonering'
    description   TEXT,                    -- 'Billig, høyt volum'
    is_active     BOOLEAN NOT NULL DEFAULT true,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(alias)
);

-- Leverandør-modeller med prioritert fallback per alias
CREATE TABLE ai_model_providers (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alias_id      UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE,
    provider      TEXT NOT NULL,           -- 'gemini', 'openrouter', 'anthropic'
    model         TEXT NOT NULL,           -- 'gemini/gemini-2.5-flash', 'openrouter/anthropic/claude-sonnet-4'
    api_key_env   TEXT NOT NULL,           -- 'GEMINI_API_KEY', 'OPENROUTER_API_KEY'
    priority      SMALLINT NOT NULL,       -- lavere = prøves først
    is_active     BOOLEAN NOT NULL DEFAULT true,
    UNIQUE(alias_id, model)
);

-- Jobbtype → modellalias mapping
CREATE TABLE ai_job_routing (
    job_type      TEXT PRIMARY KEY,        -- 'ai_text_process', 'whisper_postprocess', etc.
    alias         TEXT NOT NULL,           -- 'sidelinja/rutine'
    description   TEXT
);
```

### 3.3 Config-generering

SvelteKit-serveren genererer `config.yaml` fra PG ved oppstart og ved endringer i admin-panelet:

1. Les aktive aliaser og deres providers (sortert etter priority)
2. Skriv `config.yaml` til volum delt med LiteLLM-containeren
3. Restart LiteLLM (`docker restart ai-gateway`) eller send `SIGHUP`

Generert config inkluderer alltid `router_settings` og `general_settings` fra faste verdier — kun `model_list` er dynamisk.

### 3.4 Jobbkø-styrt modellvalg

Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per kontekst.
Admin kan endre mapping i `/admin/ai` uten redeploy — endringer trer i kraft
ved neste jobb som kjøres.

**Orkestrering:**

| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `orchestration_script` | `sidelinja/rutine` | LLM-kall i orkestreringsskript (SPØR/TRANSFORMER-steg) |
| `orchestration_dream` | `sidelinja/resonering` | Kreativ/utforskende orkestrering — drømmemodus |

**Bot & chat:**

| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `bot_chat` | `sidelinja/resonering` | Bot-svar i chat (Claude-agent og andre bots) |
| `bot_triage` | `sidelinja/rutine` | Triagering og klassifisering av innkommende meldinger |
| `agent_respond` | `sidelinja/resonering` | Claude chat-agent svar (legacy — bruk `bot_chat`) |

**Analyse & klassifisering:**

| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `suggest_edges` | `sidelinja/rutine` | AI-foreslåtte topics og mentions ved ny node |
| `summarize` | `sidelinja/rutine` | Oppsummering av kommunikasjonsnoder og innhold |
| `classify` | `sidelinja/rutine` | Klassifisering av innhold (node_kind, tags, prioritet) |

**Prosessering:**

| Kontekst | Standard alias | Begrunnelse |
|---|---|---|
| `ai_text_process` | `sidelinja/rutine` | Tekstvasking, høyt volum |
| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum |
| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum |
| `simple_prompt` | `sidelinja/rutine` | Standard LLM-kall via synops-ai prompt |
| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress |

**Teknisk:**
Maskinrommet slår opp modellalias via `ai_admin::resolve_routing_or_default(db, kontekst)`.
Resultatet sendes som miljøvariabel til CLI-verktøy (f.eks. `AI_EDGES_MODEL`, `AI_SUMMARY_MODEL`).
CLI-verktøy som `synops-ai` gjør oppslag direkte i tabellen via `--job-type`-flagget.

### 3.5 Admin-panel (`/admin/ai`)

Admin-panelet lar administrator:
* Se og redigere modellaliaser og deres fallback-liste (drag-and-drop prioritering)
* Aktivere/deaktivere individuelle leverandør-modeller
* Endre jobbtype → alias mapping
* Se live-status: hvilke leverandører som svarer, responstider
* Trigge config-regenerering og LiteLLM-restart

## 4. Docker-oppsett

```yaml
# /srv/synops/docker-compose.yml
ai-gateway:
  image: ghcr.io/berriai/litellm:main-stable
  restart: unless-stopped
  command: --config /etc/litellm/config.yaml
  environment:
    LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
    GEMINI_API_KEY: ${GEMINI_API_KEY:-}
    ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
    XAI_API_KEY: ${XAI_API_KEY:-}
    OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
  volumes:
    - /srv/synops/config/litellm:/etc/litellm
  networks:
    - sidelinja-net  # intern — maskinrommet når gateway via container-IP
  healthcheck:
    test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen(\"http://localhost:4000/health/liveliness\")"]
    interval: 15s
    timeout: 5s
    retries: 3
```

### 4.1 Config-generering

`config.yaml` genereres fra PG-tabellene med `scripts/generate-litellm-config.sh`.
Scriptet filtrerer bort providers med tomme API-nøkler i `.env`, slik at bare
leverandører med gyldige nøkler inkluderes. Kjør med `--restart` for å restarte
containeren etter generering.

```bash
# Generer og restart:
scripts/generate-litellm-config.sh --restart
```

## 5. Prompt-kvalitetssikring (Promptfoo)

Alle LLM-prompts i Sidelinja testes systematisk med [Promptfoo](https://promptfoo.dev) før de brukes i produksjon. Dette er spesielt viktig fordi vi jobber med norsk tekst, der modellkvaliteten varierer kraftig mellom leverandører.

### 5.1 Hva vi tester
Hver jobbtype som bruker LLM har et tilhørende testsett:

| Jobbtype | Testsett | Eksempler på assertions |
|---|---|---|
| `whisper_postprocess` | Norske transkripsjoner med kjente feil | Egennavn korrigert, setningsflyt bevart |
| `openrouter_analyze` | Episoder med kjent metadata | Riktig tittel, kapitler matcher innhold |
| `research_clip` | Nyhetsartikler med kjente aktører/fakta | Aktører identifisert, faktoider korrekte |
| `live_factoid_eval` | Transkripsjons-chunks med kjente entiteter | Riktig entity-match, lav falsk-positiv-rate |

### 5.2 Hva vi sammenligner
Promptfoo kjøres mot alle kandidatmodeller via AI Gateway:

```yaml
# promptfoo-config.yaml
providers:
  - id: "openai:chat:sidelinja/rutine"
    config:
      apiBaseUrl: "http://localhost:4000/v1"
      apiKey: "${LITELLM_MASTER_KEY}"
  - id: "openai:chat:sidelinja/resonering"
    config:
      apiBaseUrl: "http://localhost:4000/v1"
      apiKey: "${LITELLM_MASTER_KEY}"
```

Dette lar oss svare på:
* Klarer Gemini (gratis) denne oppgaven like bra som Claude (betalt)?
* Fungerer prompten på norsk, eller trenger vi en annen formulering?
* Har en modelloppgradering hos leverandøren degradert kvaliteten?

### 5.3 Når vi kjører tester
* **Ved ny prompt:** Før den tas i bruk i produksjon
* **Ved modellbytte:** Før en leverandør/modell settes som primær for en jobbtype
* **Periodisk (CI):** Månedlig cron-jobb i Forgejo Actions kjører `promptfoo eval` mot alle testsett. Resultater postes som issue ved regresjoner. Leverandører oppdaterer modeller uten varsel — automatisk regresjonssjekk fanger dette opp.
* **Ved kvalitetsklager:** Når redaksjonen rapporterer dårlig output

### 5.4 Lagring av testsett
Testsett og promptfoo-config versjonskontrolleres i Git under `tests/prompts/`. Testdata er norske eksempler fra faktiske episoder og artikler.

```
tests/prompts/
├── promptfooconfig.yaml
├── whisper_postprocess/
│   ├── prompt.txt
│   └── dataset.json
├── metadata_extract/
│   ├── prompt.txt
│   └── dataset.json
└── research_clip/
    ├── prompt.txt
    └── dataset.json
```

## 6. Tokenregnskap og kostnadskontroll

### 6.1 Token-logging per samlings-node

Rust-workeren logger tokenforbruk etter hvert AI-kall. Dataen lagres i PG:

```sql
CREATE TABLE ai_usage_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    collection_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    job_id          UUID REFERENCES job_queue(id) ON DELETE SET NULL,
    model_alias     TEXT NOT NULL,          -- 'sidelinja/rutine'
    model_actual    TEXT,                   -- 'gemini/gemini-2.5-flash' (fra LiteLLM-respons)
    prompt_tokens   INT NOT NULL,
    completion_tokens INT NOT NULL,
    total_tokens    INT NOT NULL,
    estimated_cost  NUMERIC(10, 6),         -- USD, beregnet fra kjente priser
    job_type        TEXT,                   -- 'ai_text_process', etc.
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_ai_usage_collection_month ON ai_usage_log (collection_node_id, created_at);
```

**Flyten:**
1. Rust-worker sender AI-kall via gateway, får tilbake `usage` i responsen
2. Worker skriver rad til `ai_usage_log` med collection_node_id, tokens og modellinfo
3. Estimert kostnad beregnes fra en enkel prisliste i config (oppdateres manuelt)

### 6.2 Visning -- to nivåer

**Admin (`/admin/ai`):**
Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-node/modell/periode. Identifiserer kostnadsdrivere.

**Samlings-node (sidebar-widget):**
Enkel tekst-indikator i sidebar: `12.4k tokens denne uken`. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer -- det krever et definert budsjett for å gi mening, og det er overkill for MVP.

### 6.3 Budsjett per samlings-node og bruker (implementert)

Budsjettgrense for AI-bruk, implementert i oppgave 28.3:

- Budsjett lagres som JSONB-metadata på samlings- eller brukernoden: `{ "ai_budget": { "monthly_limit_usd": 50 } }`
- `synops-ai prompt` sjekker budsjett via `--collection-id`/`--user-id` flagg
- `maskinrommet/ai_process` sjekker samlings- og brukerbudsjett automatisk før AI-kall
- Estimert kostnad beregnes fra tokens: $3/MTok input, $15/MTok output (konservativt gjennomsnitt)
- Aggregering: `SUM(prompt_tokens, completion_tokens)` fra `ai_usage_log` for inneværende måned
- Ved budsjett overskredet: AI-kall blokkeres, `work_item`-node opprettes med tag `budget_exceeded`
- `ai_usage_log.requested_by` sporer hvilken bruker som utløste kallet (migrasjon 029)
- Indekser: `(collection_node_id, created_at)` og `(requested_by, created_at)` for effektiv aggregering
- Fail-open: ved DB-feil i budsjettsjekk tillates kallet (unngår at feil blokkerer arbeid)

### 6.4 Per-episode maks-kostnad
Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5).

## 7. Dataklassifisering (ref. docs/arkitektur.md 2.2)

| Data | Kategori | Detaljer |
|---|---|---|
| LiteLLM config.yaml | Gjenskapbar (Git) | Versjonskontrollert |
| API-nøkler | Kritisk (.env) | Aldri i Git |
| Token-bruk-logger | Flyktig (TTL 90 dager) | For kostnadsoversikt, ryddes automatisk |
| Promptfoo testsett | Gjenskapbar (Git) | `tests/prompts/` — versjonskontrollert |
| Promptfoo testresultater | Flyktig (lokal) | Kjøres on-demand, ikke lagret permanent |

## 8. Kildevern-modus (proposal)
For sensitive redaksjonelle diskusjoner kan en lokal LLM-leverandør (Ollama/vLLM) registreres som `sidelinja/lokal` i config. Channels/møter med `kildevern: true` ruter all AI-prosessering til denne modellen — data forlater aldri serveren. Se `docs/proposals/kildevern_modus.md`.

## 9. Instruks for Claude Code
* All AI-kode skal peke på `http://ai-gateway:4000/v1` — aldri direkte til leverandør
* Bruk modellaliaser (`sidelinja/rutine`, `sidelinja/resonering`) — aldri hardkod leverandør-spesifikke modellnavn i applikasjonskode
* API-nøkler i `.env`, aldri i config-filer eller kode
* Test alltid med norsk innhold før en ny modell/leverandør tas i bruk for en produksjonsoppgave
* Kjør `promptfoo eval` før du endrer prompts eller bytter modell for en jobbtype
* Nye jobbtyper som bruker LLM skal ha et tilhørende testsett i `tests/prompts/` før de merges
