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
5 changes: 5 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""

# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
# INSIGHTS_MAX_TOKENS=""


# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

## [Unreleased]

## [1.28.28] — 2026-07-11 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish

The user-level "Local (OpenAI-compatible)" provider now speaks the standard
JSON wire: it sends `response_format` and, when an endpoint rejects it, falls
back once and remembers that endpoint's dialect — so LiteLLM, OpenRouter,
vLLM, LM Studio and plain Ollama all work from the same settings form. The
form and the provider docs now say so explicitly. Gateways that wrap a
Claude-family model behind a synthesized tool call are parsed correctly
instead of yielding silently empty insights.

The briefing token budget is configurable (`INSIGHTS_MAX_TOKENS`, default
raised so full briefings stop truncating), and a reply that was cut off
mid-JSON now says exactly that instead of a generic parse error. When the
number-grounding check withholds a briefing, the card now explains why
instead of pretending nothing was generated. The provider test button
distinguishes "the endpoint rejected the request" from "could not reach the
endpoint".

Sleep repair follow-up: re-syncing a history whose sleep rows predate the
stable segment keys could erase those nights — the old rows were swept while
their replacements collided with a second uniqueness rule and were silently
dropped. Re-imports now recognise such rows by their natural identity
(type, instant, stage) and migrate them in place: fresh value, new key,
restored if deleted. A full sync after updating heals any history affected.

## [1.28.27] — 2026-07-11 — Runs on CPUs without AVX2

Self-hosts on older x86-64 CPUs — Celeron/Atom-class NAS boxes, pre-2013
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ services:
# in .env reaches the container (the `environment:` block is a whitelist).
# See docs/integrations/ai-providers.md.
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
# Daily-briefing output-token ceiling (optional; default 2500, clamped
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
# Listed here so an operator value in .env reaches the container (the
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
# v1.17.1 — SMTP transport for the email notification channel (optional,
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
# toggles implicit TLS). Listed here so operator values in .env reach the
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.27
version: 1.28.28
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down
35 changes: 35 additions & 0 deletions docs/integrations/ai-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,35 @@ internal endpoint.
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).

### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)

The **Local** provider is also the gateway path: any service that
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
whether it runs on your host or not. The OPENAI provider deliberately
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
host), so a gateway always goes through **Local**:

1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
2. Base URL is the gateway's OpenAI-compatible root and **ends with
`/v1`** — e.g. `https://litellm.example.com/v1` or
`https://openrouter.ai/api/v1`.
3. API key is **optional** — set it when the gateway requires a
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
otherwise. It is encrypted at rest like every other credential.
4. Model is whatever the gateway routes — e.g.
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.

JSON surfaces send the standard
`response_format: { type: "json_object" }`; an endpoint that rejects
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.

The **admin** server-key path has a separate guard: an admin-set
custom base URL must additionally be allowed via
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
user-level Local provider does not read that variable — it is
governed by the SSRF guard above.

## ChatGPT OAuth (Codex)

The `CHATGPT_OAUTH` provider routes generations via the
Expand Down Expand Up @@ -283,6 +312,12 @@ Lower it when running on a tight budget — the 24h cache already
short-circuits read traffic; only force-regens and cache-misses cost
tokens.

`INSIGHTS_MAX_TOKENS` (default `2500`, clamped to 500–8000) sets the
output-token ceiling for the daily-briefing generation. Raise it if a
verbose model gets its briefing cut off mid-JSON — the API reports
that case as "AI response was cut off" rather than the generic
invalid-JSON error.

## Connection test

`POST /api/ai/test` runs a one-shot probe against the resolved
Expand Down
6 changes: 5 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,9 @@
"failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell.",
"failedDescriptionTimeout": "Die KI-Anfrage hat das Zeitlimit überschritten, bevor das Briefing fertig war. Erhöhe für ein langsames lokales Modell in den Einstellungen das Antwort-Zeitlimit und versuche es dann erneut.",
"failedDescriptionProvider": "Der KI-Anbieter hat die Anfrage abgelehnt. Prüfe Anbieter und API-Schlüssel in den KI-Einstellungen und versuche es erneut.",
"failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut."
"failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut.",
"omittedTitle": "Briefing zurückgehalten",
"omittedUngroundedDescription": "Das erstellte Briefing nannte eine Zahl, die sich nicht anhand deiner Daten überprüfen ließ, deshalb wurde es nicht angezeigt. Versuche es erneut."
},
"trendsRow": {
"title": "Trends",
Expand Down Expand Up @@ -4629,6 +4631,7 @@
"anthropicKeyLabel": "Anthropic API-Key",
"baseUrlLabel": "Base URL",
"localKeyLabel": "API-Key (optional)",
"localDescription": "Hier funktioniert jeder OpenAI-kompatible Endpunkt — ein lokales Modell (Ollama, LM Studio) genauso wie ein Gateway wie LiteLLM, OpenRouter oder vLLM. Die Basis-URL endet auf /v1; der API-Schlüssel ist optional.",
"savedPreview": "(gespeichert {preview})",
"savedShort": "(gespeichert)",
"saveCta": "Speichern",
Expand Down Expand Up @@ -4704,6 +4707,7 @@
"testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.",
"testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.",
"testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.",
"testReasonBadRequest": "Der Anbieter hat die Anfrage abgelehnt (HTTP {status}) — der Endpunkt hat geantwortet; das Problem liegt also am Anfrageformat oder Modellnamen, nicht an der Verbindung.",
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
"coachMemory": {
"title": "Woran sich der Coach erinnert",
Expand Down
6 changes: 5 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,9 @@
"failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model.",
"failedDescriptionTimeout": "The AI request ran out of time before the briefing was ready. For a slow local model, raise the AI response timeout in Settings, then try again.",
"failedDescriptionProvider": "The AI provider rejected the request. Re-check the provider and API key in AI settings, then try again.",
"failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while."
"failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while.",
"omittedTitle": "Briefing withheld",
"omittedUngroundedDescription": "The generated briefing referenced a figure that couldn't be verified against your data, so it wasn't shown. Try again."
},
"trendsRow": {
"title": "Trends",
Expand Down Expand Up @@ -4629,6 +4631,7 @@
"anthropicKeyLabel": "Anthropic API key",
"baseUrlLabel": "Base URL",
"localKeyLabel": "API key (optional)",
"localDescription": "Any OpenAI-compatible endpoint works here — a local model (Ollama, LM Studio) as well as a gateway such as LiteLLM, OpenRouter or vLLM. The base URL ends with /v1; the API key is optional.",
"savedPreview": "(saved {preview})",
"savedShort": "(saved)",
"saveCta": "Save",
Expand Down Expand Up @@ -4704,6 +4707,7 @@
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonBadRequest": "The provider rejected the request (HTTP {status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "What the Coach remembers",
Expand Down
6 changes: 5 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,9 @@
"failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento.",
"failedDescriptionTimeout": "La solicitud de IA agotó el tiempo de espera antes de que el resumen estuviera listo. Para un modelo local lento, aumenta el tiempo de espera de respuesta en Ajustes y vuelve a intentarlo.",
"failedDescriptionProvider": "El proveedor de IA rechazó la solicitud. Revisa el proveedor y la clave de API en los ajustes de IA y vuelve a intentarlo.",
"failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato."
"failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato.",
"omittedTitle": "Resumen retenido",
"omittedUngroundedDescription": "El resumen generado mencionaba una cifra que no se pudo verificar con tus datos, así que no se mostró. Inténtalo de nuevo."
},
"trendsRow": {
"title": "Tendencias",
Expand Down Expand Up @@ -4629,6 +4631,7 @@
"anthropicKeyLabel": "Clave de API de Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Clave de API (opcional)",
"localDescription": "Aquí funciona cualquier endpoint compatible con OpenAI: un modelo local (Ollama, LM Studio) o una pasarela como LiteLLM, OpenRouter o vLLM. La URL base termina en /v1; la clave de API es opcional.",
"savedPreview": "(guardada {preview})",
"savedShort": "(guardada)",
"saveCta": "Guardar",
Expand Down Expand Up @@ -4704,6 +4707,7 @@
"testReasonCredentials": "El proveedor rechazó las credenciales — vuelve a autenticarte en los ajustes de IA.",
"testReasonRateLimited": "El proveedor limitó la solicitud — inténtalo de nuevo en breve.",
"testReasonServerError": "El proveedor de IA devolvió un error del servidor.",
"testReasonBadRequest": "El proveedor rechazó la solicitud (HTTP {status}): el endpoint respondió, así que el problema está en el formato de la solicitud o en el nombre del modelo, no en la conexión.",
"testReasonUnreachable": "No se pudo contactar con el proveedor de IA.",
"coachMemory": {
"title": "Lo que el Coach recuerda",
Expand Down
6 changes: 5 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,9 @@
"failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent.",
"failedDescriptionTimeout": "La requête IA a dépassé le délai avant que le briefing soit prêt. Pour un modèle local lent, augmentez le délai de réponse dans les Paramètres, puis réessayez.",
"failedDescriptionProvider": "Le fournisseur d'IA a rejeté la requête. Vérifiez le fournisseur et la clé API dans les paramètres d'IA, puis réessayez.",
"failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant."
"failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant.",
"omittedTitle": "Briefing non affiché",
"omittedUngroundedDescription": "Le briefing généré mentionnait un chiffre qui n'a pas pu être vérifié avec tes données, il n'a donc pas été affiché. Réessaie."
},
"trendsRow": {
"title": "Tendances",
Expand Down Expand Up @@ -4629,6 +4631,7 @@
"anthropicKeyLabel": "Clé API Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Clé API (facultative)",
"localDescription": "Tout point de terminaison compatible OpenAI fonctionne ici — un modèle local (Ollama, LM Studio) comme une passerelle telle que LiteLLM, OpenRouter ou vLLM. L'URL de base se termine par /v1 ; la clé API est facultative.",
"savedPreview": "(enregistrée {preview})",
"savedShort": "(enregistrée)",
"saveCta": "Enregistrer",
Expand Down Expand Up @@ -4704,6 +4707,7 @@
"testReasonCredentials": "Le fournisseur a rejeté les identifiants — réauthentifiez-vous dans les réglages IA.",
"testReasonRateLimited": "Le fournisseur a limité la requête — réessayez sous peu.",
"testReasonServerError": "Le fournisseur d’IA a renvoyé une erreur serveur.",
"testReasonBadRequest": "Le fournisseur a rejeté la requête (HTTP {status}) — le point de terminaison a répondu ; le problème vient donc du format de la requête ou du nom du modèle, pas de la connexion.",
"testReasonUnreachable": "Impossible de joindre le fournisseur d’IA.",
"coachMemory": {
"title": "Ce dont le Coach se souvient",
Expand Down
6 changes: 5 additions & 1 deletion messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,9 @@
"failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento.",
"failedDescriptionTimeout": "La richiesta IA ha superato il tempo limite prima che il briefing fosse pronto. Per un modello locale lento, aumenta il timeout di risposta nelle Impostazioni e riprova.",
"failedDescriptionProvider": "Il provider IA ha rifiutato la richiesta. Controlla il provider e la chiave API nelle impostazioni IA e riprova.",
"failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco."
"failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco.",
"omittedTitle": "Briefing non mostrato",
"omittedUngroundedDescription": "Il briefing generato citava un valore che non è stato possibile verificare con i tuoi dati, quindi non è stato mostrato. Riprova."
},
"trendsRow": {
"title": "Tendenze",
Expand Down Expand Up @@ -4629,6 +4631,7 @@
"anthropicKeyLabel": "Chiave API Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Chiave API (facoltativa)",
"localDescription": "Qui funziona qualsiasi endpoint compatibile con OpenAI: un modello locale (Ollama, LM Studio) oppure un gateway come LiteLLM, OpenRouter o vLLM. L'URL di base termina con /v1; la chiave API è facoltativa.",
"savedPreview": "(salvata {preview})",
"savedShort": "(salvata)",
"saveCta": "Salva",
Expand Down Expand Up @@ -4704,6 +4707,7 @@
"testReasonCredentials": "Il provider ha rifiutato le credenziali — autenticati di nuovo nelle impostazioni IA.",
"testReasonRateLimited": "Il provider ha limitato la richiesta — riprova a breve.",
"testReasonServerError": "Il provider IA ha restituito un errore del server.",
"testReasonBadRequest": "Il provider ha rifiutato la richiesta (HTTP {status}): l'endpoint ha risposto, quindi il problema riguarda il formato della richiesta o il nome del modello, non la connessione.",
"testReasonUnreachable": "Impossibile raggiungere il provider IA.",
"coachMemory": {
"title": "Ciò che il Coach ricorda",
Expand Down
6 changes: 5 additions & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,9 @@
"failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego.",
"failedDescriptionTimeout": "Żądanie AI przekroczyło limit czasu, zanim briefing był gotowy. W przypadku wolnego modelu lokalnego zwiększ limit czasu odpowiedzi w Ustawieniach i spróbuj ponownie.",
"failedDescriptionProvider": "Dostawca AI odrzucił żądanie. Sprawdź dostawcę i klucz API w ustawieniach AI, a następnie spróbuj ponownie.",
"failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę."
"failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę.",
"omittedTitle": "Briefing wstrzymany",
"omittedUngroundedDescription": "Wygenerowany briefing zawierał liczbę, której nie udało się zweryfikować na podstawie Twoich danych, więc nie został wyświetlony. Spróbuj ponownie."
},
"trendsRow": {
"title": "Trendy",
Expand Down Expand Up @@ -4629,6 +4631,7 @@
"anthropicKeyLabel": "Klucz API Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Klucz API (opcjonalny)",
"localDescription": "Działa tu każdy punkt końcowy zgodny z OpenAI — model lokalny (Ollama, LM Studio) albo brama taka jak LiteLLM, OpenRouter czy vLLM. Adres bazowy kończy się na /v1; klucz API jest opcjonalny.",
"savedPreview": "(zapisano {preview})",
"savedShort": "(zapisano)",
"saveCta": "Zapisz",
Expand Down Expand Up @@ -4704,6 +4707,7 @@
"testReasonCredentials": "Dostawca odrzucił dane logowania — uwierzytelnij się ponownie w ustawieniach AI.",
"testReasonRateLimited": "Dostawca ograniczył żądanie — spróbuj ponownie za chwilę.",
"testReasonServerError": "Dostawca AI zwrócił błąd serwera.",
"testReasonBadRequest": "Dostawca odrzucił żądanie (HTTP {status}) — punkt końcowy odpowiedział, więc problem dotyczy formatu żądania lub nazwy modelu, a nie łączności.",
"testReasonUnreachable": "Nie udało się połączyć z dostawcą AI.",
"coachMemory": {
"title": "Co Coach pamięta",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.27",
"version": "1.28.28",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.27";
/* @sw-version-fallback */ "v1.28.28";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
Expand Down
Loading
Loading