diff --git a/.env.production.example b/.env.production.example index 11a138c88..94dfbfed9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -184,6 +184,11 @@ API_TOKEN_HMAC_KEY="" # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6cc3e0c..0c41aa958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 67d5d65d1..80dbb49fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9684fc0b9..03c3eebea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index bda355c87..841572745 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -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 @@ -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 diff --git a/messages/de.json b/messages/de.json index 036e7e84a..cca68b5f1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", @@ -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", @@ -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", diff --git a/messages/en.json b/messages/en.json index f4ec0ff34..4a0fc6c37 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", @@ -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", @@ -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", diff --git a/messages/es.json b/messages/es.json index ce059ab9b..41b9df989 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", @@ -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", @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 4e00eb909..1b3c8846e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", @@ -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", @@ -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", diff --git a/messages/it.json b/messages/it.json index a1b859871..f256c5613 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", @@ -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", @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 2e5596471..ed624c9c1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", @@ -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", @@ -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", diff --git a/package.json b/package.json index 99de08eee..4c630a11f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index e601d7ac5..8af20350a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 054150966..e983939cb 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -69,8 +69,14 @@ interface TestFailureEnvelope { data: { ok: false; providerType: string; - reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable"; + reasonCode: + | "credentials" + | "rate_limited" + | "server_error" + | "bad_request" + | "unreachable"; reason: string; + httpStatus: number | null; }; error: null; } @@ -170,6 +176,40 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", ( expect(body.data.reasonCode).toBe("unreachable"); expect(body.data.reason).toMatch(/could not reach/i); }); + + // v1.28.28 (#470) — a 4xx means the endpoint ANSWERED and rejected the + // request shape / model name; lumping it into "unreachable" sent operators + // debugging connectivity when the fix was the model field or a gateway's + // parameter strictness. + it("categorises a 400 as bad_request, not unreachable", async () => { + makeProviderThatThrows( + Object.assign(new Error("Local AI request failed (400)"), { + httpStatus: 400 as const, + bodyExcerpt: '{"error":{"message":"unknown model: llama9"}}', + }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(200); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("bad_request"); + expect(body.data.reason).toMatch(/HTTP 400/); + expect(body.data.reason).toMatch(/not connectivity/i); + expect(body.data.httpStatus).toBe(400); + // Still secret-free: the upstream body excerpt is never echoed. + expect(body.data.reason).not.toMatch(/llama9/); + }); + + it("categorises a 404 (wrong path / model route) as bad_request", async () => { + makeProviderThatThrows( + Object.assign(new Error("Local AI request failed (404)"), { + httpStatus: 404 as const, + }), + ); + const response = await POST(emptyRequest() as never); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("bad_request"); + expect(body.data.httpStatus).toBe(404); + }); }); // v1.4 fix: the dropdown in /settings was not honoured — the test always diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index be0432a5f..14718f4b7 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -113,6 +113,9 @@ export const POST = apiHandler(async (request: NextRequest) => { providerType: provider.type, reasonCode, reason, + // v1.28.28 (#470) — the upstream status (secret-free) so the client + // can localise "rejected the request (HTTP 400)" with the real number. + httpStatus: err.httpStatus ?? null, }); } }); @@ -123,7 +126,11 @@ export const POST = apiHandler(async (request: NextRequest) => { * legacy / unmapped code. Secret-free by construction. */ type TestFailureCode = - "credentials" | "rate_limited" | "server_error" | "unreachable"; + | "credentials" + | "rate_limited" + | "server_error" + | "bad_request" + | "unreachable"; /** * Map an upstream provider failure to a human-readable, secret-free @@ -161,6 +168,16 @@ function classifyTestFailure( reason: "The AI provider returned a server error.", }; } + // v1.28.28 (#470) — any other 4xx (400 / 404 / 405 / 415 / 422, …) means + // the endpoint ANSWERED and rejected the request shape or model name. The + // old lump into "unreachable" sent operators debugging connectivity when + // the fix was the model field or a gateway's parameter strictness. + if (status >= 400 && status < 500) { + return { + reasonCode: "bad_request", + reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`, + }; + } return { reasonCode: "unreachable", reason: "Could not reach the AI provider.", diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 2910a606b..84d16bda4 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -46,7 +46,7 @@ import { singleUserTurn, type InsightResult, } from "@/lib/ai/types"; -import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; import { recordBriefingFailure, @@ -597,7 +597,10 @@ export const POST = apiHandler((request: NextRequest) => system: buildSystemPromptWithReferences(locale, referenceMetrics), user: userPrompt, temperature: 0.3, - maxTokens: 1500, + // v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS, + // default 2500). The old fixed 1500 truncated verbose models' + // briefing JSON mid-string → generic invalid-JSON 422. + maxTokens: resolveInsightsMaxTokens(), // v1.21.5 — wider upstream budget so the reasoning-heavy briefing // generation is not aborted at the client's 60 s default on large // accounts (which returned an empty briefing + trend narrative). @@ -735,6 +738,18 @@ export const POST = apiHandler((request: NextRequest) => // so the React Query mutation can read the JSON body and surface a // readable message. Same fix pattern as v1.4.5 ai/test. void recordBriefingFailure({ userId, reason: "invalid-json", locale }); + // v1.28.28 (#470) — truncation-aware: `finishReason === "length"` means + // the model hit the output-token ceiling and the JSON was cut off + // mid-string, which is a budget problem, not a model-quality one. Say + // so with a distinct errorCode instead of the generic invalid-JSON line. + if (result.finishReason === "length") { + annotate({ meta: { insights_response_truncated: true } }); + return apiError( + "AI response was cut off before the JSON completed — raise the token limit (INSIGHTS_MAX_TOKENS)", + 422, + { errorCode: "ai_response_truncated" }, + ); + } return apiError("AI response was not valid JSON", 422); } @@ -743,6 +758,13 @@ export const POST = apiHandler((request: NextRequest) => // briefing's SHAPE; this asserts every number the briefing prose restates // traces to a `features.signalsOfDay` figure. On a miss: one corrective // retry, then drop the briefing block rather than ship a fabricated number. + // + // v1.28.28 (#470) — when the gate strips the briefing, SAY SO: the 200 + // used to carry a silently-null briefing, the card showed "no briefing + // yet", and the regenerate button read as doing nothing. The additive + // `briefingOmittedReason` field lets the card render an honest "a figure + // couldn't be verified, so the briefing wasn't shown" state instead. + let briefingOmittedReason: "ungrounded" | null = null; { const signals = features.signalsOfDay ?? null; let ungrounded = findUngroundedBriefingNumbers( @@ -763,7 +785,8 @@ export const POST = apiHandler((request: NextRequest) => system: buildSystemPromptWithReferences(locale, referenceMetrics), user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, temperature: 0.3, - maxTokens: 1500, + // v1.28.28 (#470) — same env-tunable ceiling as the first call. + maxTokens: resolveInsightsMaxTokens(), // v1.21.5 — wider upstream budget; see the first generation call. // v1.25 — honours the per-user response-timeout setting. timeoutMs: effectiveTimeoutMs, @@ -794,6 +817,7 @@ export const POST = apiHandler((request: NextRequest) => // fabricated figure. The structured recommendations/citations stay. if (ungrounded.length > 0 && insights && typeof insights === "object") { (insights as Record).dailyBriefing = null; + briefingOmittedReason = "ungrounded"; annotate({ action: { name: "insights.generate.briefing_grounding_stripped" }, meta: { ungroundedCount: ungrounded.length }, @@ -880,6 +904,13 @@ export const POST = apiHandler((request: NextRequest) => }, }); - return apiSuccess({ insights, cached: false, legacyPayload: false }); + return apiSuccess({ + insights, + cached: false, + legacyPayload: false, + // v1.28.28 (#470) — additive: non-null when the grounding gate stripped + // the briefing from THIS generation, so the card can explain the hole. + briefingOmittedReason, + }); }), ); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 8340bd4b5..f80c4d125 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -471,6 +471,10 @@ export default function InsightsPage() { // v1.25.3 — failure class points the empty-state hint at the right // lever (raise the response timeout vs re-check the provider). generationFailureClass={advisor.generationFailureClass} + // v1.28.28 (#470) — the grounding gate stripped the last regenerated + // briefing; the card explains the omission instead of "no briefing + // yet" (which made the regenerate button read as doing nothing). + omittedReason={advisor.briefingOmittedReason} /> ) : null, vitals: , diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 980498c04..f4cb973a6 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -65,7 +65,13 @@ import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsor // but the slot is one future-callback-field away from cache poisoning. const DASHBOARD_QUERY_OPTS = { staleTime: 60_000, - refetchOnWindowFocus: false, + // v1.28.28 — refetch on window focus: the dashboard is exactly the surface + // a user leaves open in a background tab while editing its tile selection + // elsewhere; without a focus refetch the stale layout sat visible for up to + // the poll interval ("saved but nothing changed"). staleTime still bounds + // the cost to at most one refetch per minute, and the server side is SWR- + // cached, so a focus flick is a cheap cache hit. + refetchOnWindowFocus: true, } as const; // v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches. diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 65eebed90..6f244783b 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -134,6 +134,14 @@ interface DailyBriefingProps { * Absent / null → the generic failed-description holds. */ generationFailureClass?: BriefingFailureClass | null; + /** + * v1.28.28 (#470) — the last regenerate produced a briefing that restated a + * figure the grounding gate could not verify against the user's data, so it + * was withheld rather than shown. Renders a distinct, calm empty state + * ("wasn't shown, try again") instead of the generic "no briefing yet", + * which made the regenerate button read as doing nothing. + */ + omittedReason?: "ungrounded" | null; /** * Optional slot for a meta control mounted in the card header — the * comparison toggle migrates here from the hero in commit 5. @@ -358,6 +366,7 @@ export function DailyBriefing({ noProviderStale = false, generationFailed = false, generationFailureClass = null, + omittedReason = null, metaSlot, }: DailyBriefingProps) { const { t } = useTranslations(); @@ -570,15 +579,23 @@ export function DailyBriefing({ // text to fall back on, say so honestly ("couldn't generate") // rather than the generic "no briefing yet". The CTA below is the // same explicit regenerate — it retries the failed generation. + // v1.28.28 (#470) — a grounding-gate omission gets its own calm + // wording: the generation SUCCEEDED but restated an unverifiable + // figure, so the briefing was withheld. Without this the card + // read "no briefing yet" and the button looked like a no-op. title={ - generationFailed - ? t("insights.dailyBriefing.failedTitle") - : t("insights.dailyBriefing.emptyTitle") + omittedReason === "ungrounded" + ? t("insights.dailyBriefing.omittedTitle") + : generationFailed + ? t("insights.dailyBriefing.failedTitle") + : t("insights.dailyBriefing.emptyTitle") } description={ - generationFailed - ? t(failedDescriptionKey) - : t("insights.dailyBriefing.emptyDescription") + omittedReason === "ungrounded" + ? t("insights.dailyBriefing.omittedUngroundedDescription") + : generationFailed + ? t(failedDescriptionKey) + : t("insights.dailyBriefing.emptyDescription") } action={ onRegenerate ? ( @@ -600,7 +617,7 @@ export function DailyBriefing({ {regenerating ? t("insights.heroRegenerating") - : generationFailed + : generationFailed || omittedReason === "ungrounded" ? t("insights.dailyBriefing.retryAction") : t("insights.dailyBriefing.emptyAction")} diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 83f0eaadc..c6a504a0b 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -88,6 +88,14 @@ export interface InsightAdvisorPayload { * generic failed-description holds. */ generationFailureClass?: BriefingFailureClass | null; + /** + * v1.28.28 (#470) — set by the POST when the number-grounding gate stripped + * the freshly generated briefing (after its one corrective retry). Without + * it the 200 carried a silently-null briefing and the card's "no briefing + * yet" made the regenerate button read as doing nothing. Only ever present + * on a force-regenerate response; absent on the read path. + */ + briefingOmittedReason?: "ungrounded" | null; } /** @@ -304,6 +312,13 @@ export interface UseInsightsAdvisorResult { * the payload predates the field. */ generationFailureClass: BriefingFailureClass | null; + /** + * v1.28.28 (#470) — non-null when the LAST regenerate's grounding gate + * stripped the briefing. The card renders a distinct, calm "a figure + * couldn't be verified, so the briefing wasn't shown — try again" state + * instead of the generic "no briefing yet". + */ + briefingOmittedReason: "ungrounded" | null; } /** @@ -394,5 +409,9 @@ export function useInsightsAdvisorQuery( generationFailed: query.data?.payload?.generationFailed ?? false, // Absent → no specific lever hint; the generic failed-description holds. generationFailureClass: query.data?.payload?.generationFailureClass ?? null, + // v1.28.28 (#470) — only a force-regenerate response carries the field + // (setQueryData writes that response into this cache); the read path's + // GET never does, so the hint clears on the next successful read. + briefingOmittedReason: query.data?.payload?.briefingOmittedReason ?? null, }; } diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index a482df7e4..a7337de55 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -89,6 +89,13 @@ export function LocalProviderForm({ return (
+ {/* v1.28.28 (#470) — name the gateway path explicitly. The Local + provider is the ONE user-level custom-URL provider, so it is also + the documented way to reach LiteLLM / OpenRouter / vLLM and any + other OpenAI-compatible endpoint — not just an on-host model. */} +

+ {t("settings.ai.localDescription")} +