Merge pull request #474 from MBombeck/release/v1.28.28

release/v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-11 10:52:22 +02:00
commit c36fdbf789
32 files changed
+930 -62

No files matched your search

+5
View File
@@ -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
+25
View File
@@ -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
+5
View File
@@ -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
+1 -1
View File
@@ -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.
+35
View File
@@ -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
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
+41 -1
View File
@@ -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
+18 -1
View File
@@ -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.",
+35 -4
View File
@@ -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<string, unknown>).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,
});
}),
);
+4
View File
@@ -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: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
+7 -1
View File
@@ -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.
+24 -7
View File
@@ -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({
<span>
{regenerating
? t("insights.heroRegenerating")
: generationFailed
: generationFailed || omittedReason === "ungrounded"
? t("insights.dailyBriefing.retryAction")
: t("insights.dailyBriefing.emptyAction")}
</span>
@@ -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,
};
}
@@ -89,6 +89,13 @@ export function LocalProviderForm({
return (
<div data-testid="ai-provider-config-local" className="space-y-4">
{/* 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. */}
<p className="text-muted-foreground text-xs">
{t("settings.ai.localDescription")}
</p>
<div>
<Label htmlFor="ai-local-base-url">
{t("settings.ai.baseUrlLabel")}
@@ -68,6 +68,7 @@ export function RuntimeActionsRow({
model?: string;
reasonCode?: string;
reason?: string;
httpStatus?: number | null;
} | null;
error?: string | null;
};
@@ -95,7 +96,12 @@ export function RuntimeActionsRow({
if (json.data && json.data.ok === false) {
setTestOk(false);
setTestMsg(
localiseTestReason(t, json.data.reasonCode, json.data.reason) ??
localiseTestReason(
t,
json.data.reasonCode,
json.data.reason,
json.data.httpStatus,
) ??
t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }),
);
return;
+8
View File
@@ -129,6 +129,7 @@ export function localiseTestReason(
t: (key: string, params?: Record<string, string | number>) => string,
reasonCode: string | undefined,
reason: string | undefined,
httpStatus?: number | null,
): string | undefined {
switch (reasonCode) {
case "credentials":
@@ -137,6 +138,13 @@ export function localiseTestReason(
return t("settings.ai.testReasonRateLimited");
case "server_error":
return t("settings.ai.testReasonServerError");
// v1.28.28 (#470) — the endpoint answered with a 4xx: a request-shape /
// model-name problem, not connectivity. Falls back to the server's plain
// reason when the status did not arrive (legacy response shape).
case "bad_request":
return httpStatus != null
? t("settings.ai.testReasonBadRequest", { status: httpStatus })
: reason;
case "unreachable":
return t("settings.ai.testReasonUnreachable");
default:
+44 -4
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "../ai-budgets";
import {
AI_BUDGETS,
REFERENCE_AI_SEED,
resolveInsightsMaxTokens,
} from "../ai-budgets";
describe("AI_BUDGETS", () => {
it("right-sizes the per-metric status output to its 30-60 word contract", () => {
@@ -11,8 +15,10 @@ describe("AI_BUDGETS", () => {
expect(AI_BUDGETS.status.temperature).toBe(0.3);
});
it("keeps the comprehensive briefing budget at 1500 / 0.3", () => {
expect(AI_BUDGETS.comprehensive.maxTokens).toBe(1500);
it("keeps the comprehensive temperature at 0.3 (token ceiling moved to the resolver)", () => {
// v1.28.28 (#470) — the output-token ceiling left the static table for
// the env-tunable resolveInsightsMaxTokens(); no static entry lingers.
expect(AI_BUDGETS.comprehensive).not.toHaveProperty("maxTokens");
expect(AI_BUDGETS.comprehensive.temperature).toBe(0.3);
});
@@ -37,3 +43,37 @@ describe("AI_BUDGETS", () => {
expect(Number.isInteger(REFERENCE_AI_SEED)).toBe(true);
});
});
// v1.28.28 (#470) — the briefing token ceiling is env-tunable: the old fixed
// 1500 truncated real briefings on verbose models (finish_reason "length"
// mid-JSON → the generic invalid-JSON 422).
describe("resolveInsightsMaxTokens", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("defaults to 2500 when INSIGHTS_MAX_TOKENS is unset", () => {
vi.stubEnv("INSIGHTS_MAX_TOKENS", "");
expect(resolveInsightsMaxTokens()).toBe(2500);
});
it("honours a valid override", () => {
vi.stubEnv("INSIGHTS_MAX_TOKENS", "4000");
expect(resolveInsightsMaxTokens()).toBe(4000);
});
it("clamps below the 500 floor", () => {
vi.stubEnv("INSIGHTS_MAX_TOKENS", "100");
expect(resolveInsightsMaxTokens()).toBe(500);
});
it("clamps above the 8000 ceiling", () => {
vi.stubEnv("INSIGHTS_MAX_TOKENS", "99999");
expect(resolveInsightsMaxTokens()).toBe(8000);
});
it("falls back to the default on a non-numeric value", () => {
vi.stubEnv("INSIGHTS_MAX_TOKENS", "plenty");
expect(resolveInsightsMaxTokens()).toBe(2500);
});
});
+124 -5
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LocalOpenAICompatibleClient } from "../local-client";
import {
LocalOpenAICompatibleClient,
resetLocalJsonDialectCache,
} from "../local-client";
import { singleUserTurn } from "../types";
describe("LocalOpenAICompatibleClient", () => {
@@ -9,6 +12,9 @@ describe("LocalOpenAICompatibleClient", () => {
// an RFC1918 host). The safeFetch wrapper enforces the SSRF guard
// unless the operator explicitly accepts a private host.
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
// v1.28.28 (#470) — the JSON dialect is cached per baseUrl for the
// process; clear it so each test starts from the default dialect.
resetLocalJsonDialectCache();
});
afterEach(() => {
@@ -53,13 +59,16 @@ describe("LocalOpenAICompatibleClient", () => {
expect(body.messages[1].content).toContain("user-input");
expect(body.messages[1].content.toLowerCase()).toContain("json");
// CRUCIAL: do not send response_format because many local servers reject it.
// No JSON opt-in → neither the standard flag nor the legacy Ollama one.
expect(body).not.toHaveProperty("response_format");
// No JSON opt-in → no `format` field.
expect(body).not.toHaveProperty("format");
});
it("sends Ollama `format: json` only when the caller opts into JSON", async () => {
// v1.28.28 (#470) — the JSON opt-in sends the STANDARD OpenAI field so
// strict OpenAI-compatible gateways (LiteLLM / OpenRouter / vLLM) accept
// the request. The Ollama-native top-level `format` field is gone from
// the wire entirely.
it("sends standard `response_format` (and never `format`) when the caller opts into JSON", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
@@ -86,10 +95,120 @@ describe("LocalOpenAICompatibleClient", () => {
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.format).toBe("json");
expect(body.response_format).toEqual({ type: "json_object" });
expect(body).not.toHaveProperty("format");
expect(body.seed).toBe(99);
});
it("self-heals a response_format rejection: one retry without the flag + cached dialect", async () => {
const okReply = {
ok: true,
json: () =>
Promise.resolve({
choices: [
{ message: { content: '{"summary":"x"}' }, finish_reason: "stop" },
],
usage: { total_tokens: 3 },
}),
};
const mockFetch = vi
.fn()
// 1st: strict gateway 400s on the unknown field.
.mockResolvedValueOnce({
ok: false,
status: 400,
text: () =>
Promise.resolve(
'{"error":{"message":"litellm.BadRequestError: response_format is not supported by this endpoint"}}',
),
})
// 2nd (the healed retry) + 3rd (a fresh call) succeed.
.mockResolvedValue(okReply);
vi.stubGlobal("fetch", mockFetch);
const client = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3:8b",
baseUrl: "http://localhost:11434/v1",
});
const params = singleUserTurn({
system: "s",
user: "u",
responseFormat: "json",
});
const result = await client.generateCompletion(params);
expect(result.content).toBe('{"summary":"x"}');
expect(mockFetch).toHaveBeenCalledTimes(2);
// First attempt carried the standard flag; the healed retry dropped it
// (and did NOT resurrect the legacy Ollama `format` field).
const first = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(first.response_format).toEqual({ type: "json_object" });
const retry = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(retry).not.toHaveProperty("response_format");
expect(retry).not.toHaveProperty("format");
// The dialect is cached per baseUrl: the next call goes straight to the
// no-flag wire with no extra probe.
await client.generateCompletion(params);
expect(mockFetch).toHaveBeenCalledTimes(3);
const third = JSON.parse(mockFetch.mock.calls[2][1].body);
expect(third).not.toHaveProperty("response_format");
});
it("does NOT retry an unrelated 4xx (surfaces the structured error once)", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: () =>
Promise.resolve('{"error":{"message":"model llama9 does not exist"}}'),
});
vi.stubGlobal("fetch", mockFetch);
const client = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama9",
baseUrl: "http://localhost:11434/v1",
});
await expect(
client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
),
).rejects.toThrow("Local AI request failed (400)");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("surfaces finish_reason 'length' as finishReason on the buffered path", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: { content: '{"summary":"cut off mid' },
finish_reason: "length",
},
],
usage: { total_tokens: 1500 },
}),
}),
);
const client = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3",
baseUrl: "http://localhost:11434/v1",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
expect(result.finishReason).toBe("length");
});
it("omits Authorization header when no API key is provided", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
+126
View File
@@ -342,6 +342,132 @@ describe("OpenAIClient", () => {
);
});
// v1.28.28 (#470) — LiteLLM shims Anthropic JSON mode as a synthesized tool
// call: finish_reason "tool_calls", empty / "{}" content, the actual JSON in
// tool_calls[0].function.arguments. In JSON mode (never combined with real
// tools) the arguments string becomes the content instead of silently
// emptying the insight.
it("falls back to tool_calls[0].function.arguments when JSON mode returns empty content", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: "",
tool_calls: [
{
id: "call_1",
function: {
name: "json_response",
arguments: '{"summary":"via tool shim"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: { total_tokens: 9 },
}),
}),
);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o-mini",
baseUrl: "https://litellm.example.com/v1",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
expect(result.content).toBe('{"summary":"via tool shim"}');
});
it("also applies the tool-call shim when JSON-mode content is a bare '{}'", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: "{}",
tool_calls: [
{
id: "call_1",
function: {
name: "json_response",
arguments: '{"summary":"real payload"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
}),
}),
);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o-mini",
baseUrl: "https://litellm.example.com/v1",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
expect(result.content).toBe('{"summary":"real payload"}');
});
it("leaves the normal JSON-mode content path untouched by the tool-call shim", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: '{"summary":"prose content wins"}',
tool_calls: [
{
id: "call_1",
function: {
name: "json_response",
arguments: '{"summary":"never used"}',
},
},
],
},
finish_reason: "stop",
},
],
}),
}),
);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
expect(result.content).toBe('{"summary":"prose content wins"}');
});
it("threads the caller's abort signal onto the upstream fetch", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
+49 -2
View File
@@ -51,7 +51,11 @@ export const AI_BUDGETS = {
/**
* Comprehensive insight + dailyBriefing — the largest structured payload
* (summary + recommendations + dailyBriefing 80-200 words + signals +
* keyFindings + trendAnnotations + storyboardAnnotations). 1500 tokens.
* keyFindings + trendAnnotations + storyboardAnnotations). The output-token
* ceiling lives in `resolveInsightsMaxTokens()` below (env-tunable via
* `INSIGHTS_MAX_TOKENS`, default 2500) — the fixed 1500 that used to sit
* here truncated real briefings on verbose models (v1.28.28, #470):
* finish_reason "length" mid-JSON surfaced as the generic invalid-JSON 422.
*
* v1.21.5 — `timeoutMs` raised to 120 s for THIS surface only. The 60 s
* client default aborted the reasoning-heavy single-turn generation
@@ -74,7 +78,7 @@ export const AI_BUDGETS = {
* accounts that never set it. The worker cap in `insight-pregenerate.ts`
* derives from this value plus fixed headroom, so it tracks automatically.
*/
comprehensive: { temperature: 0.3, maxTokens: 1500, timeoutMs: 180_000 },
comprehensive: { temperature: 0.3, timeoutMs: 180_000 },
/**
* Per-metric status assessment cards — output is a single
@@ -194,3 +198,46 @@ export const AI_BUDGETS = {
*/
documentChat: { temperature: 0.3, maxTokens: 600 },
} as const satisfies Record<string, AiBudget>;
/** Bounds + default for the comprehensive / briefing output-token ceiling. */
const INSIGHTS_MAX_TOKENS_DEFAULT = 2500;
const INSIGHTS_MAX_TOKENS_MIN = 500;
const INSIGHTS_MAX_TOKENS_MAX = 8000;
/** Memo of the last parse, keyed on the raw env string so env stubs in tests re-parse. */
let insightsMaxTokensMemo: { raw: string | undefined; value: number } | null =
null;
/**
* v1.28.28 (#470) — output-token ceiling for the comprehensive briefing
* generation (the POST /api/insights/generate inline path, its grounding
* retry, and every `comprehensive-generate.ts` call site). Reads the
* optional `INSIGHTS_MAX_TOKENS` env var:
*
* - unset / non-numeric → 2500 (the old hard-coded 1500 truncated real
* briefings on verbose models — the JSON was cut mid-string and the
* user saw a generic invalid-JSON 422),
* - numeric but out of range → clamped into [500, 8000] so a typo can
* neither starve the payload nor hand a runaway budget to the provider.
*
* Parsed once per distinct raw value (memoised), same posture as
* `resolveInsightsRateLimit()` next to the route.
*/
export function resolveInsightsMaxTokens(): number {
const raw = process.env.INSIGHTS_MAX_TOKENS;
if (insightsMaxTokensMemo && insightsMaxTokensMemo.raw === raw) {
return insightsMaxTokensMemo.value;
}
let value = INSIGHTS_MAX_TOKENS_DEFAULT;
if (raw) {
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed)) {
value = Math.min(
INSIGHTS_MAX_TOKENS_MAX,
Math.max(INSIGHTS_MAX_TOKENS_MIN, parsed),
);
}
}
insightsMaxTokensMemo = { raw, value };
return value;
}
+138 -19
View File
@@ -1,4 +1,5 @@
import { safeFetch } from "@/lib/safe-fetch";
import { annotate } from "@/lib/logging/context";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import type {
AIProvider,
@@ -6,7 +7,7 @@ import type {
CompletionParams,
CompletionResult,
} from "./types";
import { buildOpenAIMessages } from "./openai-wire";
import { buildOpenAIMessages, mapFinishReason } from "./openai-wire";
interface LocalClientConfig {
apiKey?: string | null;
@@ -17,6 +18,54 @@ interface LocalClientConfig {
const STRICT_JSON_PREFIX =
"Return strict JSON only, no markdown, no commentary.\n\n";
/**
* v1.28.28 (#470) — per-endpoint JSON-mode dialect, learned at runtime.
*
* - `"response_format"` (the default): send the standard OpenAI
* `response_format: { type: "json_object" }`. Strict OpenAI-compatible
* gateways (LiteLLM, OpenRouter, vLLM) and Ollama's own `/v1` shim all
* accept it.
* - `"none"`: send no structured-output flag at all. The strict-JSON
* instruction prepended to the first user turn remains the only JSON
* steering — which is what this client shipped for years and what local
* model templates respect. (Ollama's NATIVE `format: "json"` field never
* belonged on the OpenAI-compatible `/chat/completions` wire; it was
* tolerated by Ollama but 400s on strict gateways, so the fallback stays
* minimal instead of resurrecting it.)
*
* The dialect is cached per baseUrl for the process lifetime: the first
* JSON-mode request that 4xxes with an error body referencing
* `response_format` / an unknown parameter flips the endpoint to `"none"`
* and is retried once without the flag (self-healing, mirroring the
* google-health dateFilter self-heal posture). Unrelated 4xx errors are
* NOT retried — they surface as the structured error they always were.
*/
type LocalJsonDialect = "response_format" | "none";
const jsonDialectByBaseUrl = new Map<string, LocalJsonDialect>();
/** Test hook — clears the learned per-endpoint dialect cache. */
export function resetLocalJsonDialectCache(): void {
jsonDialectByBaseUrl.clear();
}
/**
* True when a 4xx error body reads as "this endpoint rejects the
* `response_format` field" — either it names the field outright or it
* complains about an unknown/unexpected parameter.
*/
export function isResponseFormatRejection(
status: number,
bodyExcerpt: string,
): boolean {
if (status < 400 || status >= 500) return false;
return (
/response_format/i.test(bodyExcerpt) ||
/(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test(
bodyExcerpt,
)
);
}
/**
* v1.22 (#89) — absolute backstop for the streaming path. The real ceiling is
* the per-idle-gap timer (`CompletionParams.timeoutMs`); this only stops a
@@ -27,8 +76,9 @@ const STREAM_ABSOLUTE_CEILING_MS = 10 * 60_000;
/**
* Prepend the strict-JSON instruction to the FIRST user turn's text. Local
* model templates respect an in-message instruction better than the
* `response_format` flag (which many reject). Mirrors the pre-refactor
* model templates respect an in-message instruction reliably, so it is sent
* in BOTH dialects — it is the only JSON steering left when an endpoint
* rejects `response_format` (dialect `"none"`). Mirrors the pre-refactor
* behaviour, which prepended to the single user prompt.
*/
function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] {
@@ -49,11 +99,14 @@ function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] {
}
/**
* Talks to OpenAI-compatible local servers (Ollama, LocalAI, LM Studio,
* vLLM, …). Same wire format as OpenAI but without
* `response_format: { type: "json_object" }` — many local models reject the
* field outright. Instead we prepend a strict-JSON instruction to the user
* message, which is what most local model templates respect.
* Talks to OpenAI-compatible servers: local runtimes (Ollama, LocalAI,
* LM Studio, vLLM, …) AND hosted gateways (LiteLLM, OpenRouter, any
* `/v1/chat/completions` endpoint with an optional Bearer key). Same wire
* format as OpenAI; JSON surfaces send the standard
* `response_format: { type: "json_object" }` by default and self-heal to a
* no-flag dialect per endpoint when the server rejects the field (see
* `LocalJsonDialect` above). The strict-JSON instruction prepended to the
* user message stays in both dialects.
*
* v1.20.0 — tool-calling DEGRADES silently here: most local servers reject an
* unknown `tools` field, so the client never forwards it (capability flag
@@ -81,8 +134,16 @@ export class LocalOpenAICompatibleClient implements AIProvider {
private buildRequest(
params: CompletionParams,
stream: boolean,
): { url: string; headers: Record<string, string>; body: string } {
): {
url: string;
headers: Record<string, string>;
body: string;
jsonDialect: LocalJsonDialect;
} {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
// v1.28.28 (#470) — per-endpoint JSON dialect (see LocalJsonDialect).
const jsonDialect =
jsonDialectByBaseUrl.get(this.config.baseUrl) ?? "response_format";
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -113,11 +174,16 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// most OpenAI-compatible local servers honour it; servers that
// ignore it simply disregard the field. Omitted when unset.
...(params.seed !== undefined ? { seed: params.seed } : {}),
// v1.18.7 — Ollama's native structured-output switch. The JSON
// surfaces opt in via `responseFormat: "json"`; it cuts the
// first-pass JSON-failure rate that the fence-stripping safety net
// otherwise has to catch. The Coach (prose) leaves it unset.
...(params.responseFormat === "json" ? { format: "json" } : {}),
// v1.28.28 (#470) — the JSON surfaces opt in via `responseFormat:
// "json"`. Default dialect sends the STANDARD OpenAI field (the
// top-level Ollama-native `format: "json"` this client used to send
// 400s on strict OpenAI-compatible gateways — LiteLLM / OpenRouter /
// vLLM — and never belonged on the /chat/completions wire). An
// endpoint that rejects `response_format` is flipped to the no-flag
// dialect and retried once (see generateCompletion).
...(params.responseFormat === "json" && jsonDialect === "response_format"
? { response_format: { type: "json_object" } }
: {}),
// v1.22 (#89) — streaming opt-in. `stream_options.include_usage` asks
// OpenAI-compatible servers (vLLM / LM Studio / exo) to append a final
// chunk carrying the usage block so the token footer survives streaming.
@@ -126,13 +192,16 @@ export class LocalOpenAICompatibleClient implements AIProvider {
: {}),
});
return { url, headers, body };
return { url, headers, body, jsonDialect };
}
async generateCompletion(
params: CompletionParams,
): Promise<CompletionResult> {
const { url, headers, body } = this.buildRequest(params, false);
const { url, headers, body, jsonDialect } = this.buildRequest(
params,
false,
);
// safeFetch defaults: no redirect-follow (the local endpoint is the
// most exploitable on this surface — a user-controlled baseUrl that
@@ -175,6 +244,24 @@ export class LocalOpenAICompatibleClient implements AIProvider {
.slice(0, 500)
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
// v1.28.28 (#470) — dialect self-heal. A 4xx whose body names
// `response_format` (or an unknown parameter) means this endpoint
// rejects the standard JSON flag: learn the no-flag dialect for the
// baseUrl and retry ONCE without it (the recursion terminates because
// the cached dialect is now "none"). Unrelated 4xx/5xx errors are not
// retried.
if (
params.responseFormat === "json" &&
jsonDialect === "response_format" &&
isResponseFormatRejection(res.status, bodyExcerpt)
) {
jsonDialectByBaseUrl.set(this.config.baseUrl, "none");
annotate({
action: { name: "ai.local.jsonDialect" },
meta: { dialect: "none", httpStatus: res.status },
});
return this.generateCompletion(params);
}
const err = new Error(`Local AI request failed (${res.status})`);
Object.assign(err, {
httpStatus: res.status,
@@ -186,8 +273,26 @@ export class LocalOpenAICompatibleClient implements AIProvider {
throw err;
}
// v1.28.28 (#470) — the standard `response_format` request succeeded:
// pin the dialect for this endpoint so a later transient 4xx can never
// silently degrade it back.
if (
params.responseFormat === "json" &&
jsonDialect === "response_format" &&
!jsonDialectByBaseUrl.has(this.config.baseUrl)
) {
jsonDialectByBaseUrl.set(this.config.baseUrl, "response_format");
annotate({
action: { name: "ai.local.jsonDialect" },
meta: { dialect: "response_format" },
});
}
const json = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
choices?: Array<{
message?: { content?: string };
finish_reason?: string;
}>;
usage?: { total_tokens?: number };
};
@@ -210,6 +315,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
tokensUsed: json.usage?.total_tokens ?? null,
model: this.config.model,
providerType: "local",
// v1.28.28 (#470) — surface why the model stopped so the JSON callers
// can tell a token-ceiling truncation ("length") from bad output.
finishReason: mapFinishReason(json.choices?.[0]?.finish_reason),
};
}
@@ -282,7 +390,10 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// (or there is no readable body). Parse it like the non-streaming path
// rather than paying a second round-trip.
const json = (await res.json().catch(() => null)) as {
choices?: Array<{ message?: { content?: string } }>;
choices?: Array<{
message?: { content?: string };
finish_reason?: string;
}>;
usage?: { total_tokens?: number };
} | null;
const buffered = json?.choices?.[0]?.message?.content;
@@ -301,6 +412,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
tokensUsed: json?.usage?.total_tokens ?? null,
model: this.config.model,
providerType: "local",
finishReason: mapFinishReason(json?.choices?.[0]?.finish_reason),
};
}
@@ -309,6 +421,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
let sseBuffer = "";
let content = "";
let tokensUsed: number | null = null;
let finishReason: string | undefined;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const armIdle = () => {
@@ -334,7 +447,10 @@ export class LocalOpenAICompatibleClient implements AIProvider {
const payload = line.slice("data:".length).trim();
if (!payload || payload === "[DONE]") continue;
let chunk: {
choices?: Array<{ delta?: { content?: string } }>;
choices?: Array<{
delta?: { content?: string };
finish_reason?: string | null;
}>;
usage?: { total_tokens?: number };
};
try {
@@ -351,6 +467,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
if (typeof chunk.usage?.total_tokens === "number") {
tokensUsed = chunk.usage.total_tokens;
}
const chunkFinish = chunk.choices?.[0]?.finish_reason;
if (typeof chunkFinish === "string") finishReason = chunkFinish;
}
}
} catch (err) {
@@ -388,6 +506,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
tokensUsed,
model: this.config.model,
providerType: "local",
finishReason: mapFinishReason(finishReason),
};
}
}
+19 -1
View File
@@ -125,9 +125,27 @@ export class OpenAIClient implements AIProvider {
const json = (await res.json()) as OpenAIResponseJson;
const choice = json.choices?.[0];
const content = choice?.message?.content;
let content = choice?.message?.content;
const toolCalls = parseOpenAIToolCalls(choice);
// v1.28.28 (#470) — gateway JSON-mode shim. LiteLLM (and similar
// OpenAI-compatible proxies) translate `response_format: json_object`
// for an Anthropic upstream into a synthesized tool call: the reply
// comes back with `finish_reason: "tool_calls"`, an empty / "{}"
// `message.content`, and the actual JSON object in
// `tool_calls[0].function.arguments`. Since no JSON caller passes
// tools (useJsonFormat requires !hasTools), lift the arguments string
// into `content` so the payload parses instead of silently emptying
// the insight. The real tool loop (Coach F1) always has tools and
// never enters JSON mode, so it is untouched.
const trimmedContent = content?.trim() ?? "";
if (useJsonFormat && (trimmedContent === "" || trimmedContent === "{}")) {
const args = choice?.message?.tool_calls?.[0]?.function?.arguments;
if (typeof args === "string" && args.trim().length > 0) {
content = args;
}
}
// A reply with tool calls and no prose is valid (F1 tool loop); only an
// empty reply with neither content NOR tool calls is an error.
if (!content && !toolCalls) {
@@ -200,3 +200,61 @@ describe("upsertGoogleHealthMeasurements — no-op overwrite skip", () => {
expect(imported).toBe(1);
});
});
describe("upsertGoogleHealthMeasurements — natural-key migration rescue", () => {
it("re-keys a tombstoned natural-key twin instead of dropping the insert", async () => {
// externalId probe: no match (the key FORMAT changed); natural-key probe:
// the old-key row (tombstoned by the sweep) occupies the same
// (type, measuredAt, sleepStage) slot — without the rescue the insert
// would silently die on the 0055 unique via skipDuplicates.
findManyMock
.mockResolvedValueOnce([]) // externalId probe
.mockResolvedValueOnce([
{
id: "old-row",
type: "SLEEP_DURATION",
measuredAt: new Date("2026-07-08T06:30:00.000Z"),
sleepStage: "DEEP",
},
]); // natural-key rescue probe
const reading = {
type: "SLEEP_DURATION",
value: 45,
unit: "minutes",
measuredAt: new Date("2026-07-08T06:30:00.000Z"),
externalId: "name-42:sleep:2026-07-08T05:45:00.000Z",
sleepStage: "DEEP" as const,
};
const { imported } = await upsertGoogleHealthMeasurements(
"user-1",
[reading],
{ deferRollup: true },
);
expect(createManyMock).not.toHaveBeenCalled();
expect(updateMock).toHaveBeenCalledTimes(1);
const upd = (updateMock.mock.calls[0] as unknown[])[0] as {
where: { id: string };
data: Record<string, unknown>;
};
expect(upd.where).toEqual({ id: "old-row" });
expect(upd.data.externalId).toBe("name-42:sleep:2026-07-08T05:45:00.000Z");
expect(upd.data.deletedAt).toBeNull();
expect(upd.data.value).toBe(45);
expect(imported).toBe(1);
});
it("creates normally when no natural-key twin exists", async () => {
findManyMock.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
createManyMock.mockResolvedValue({ count: 1 });
const { imported } = await upsertGoogleHealthMeasurements(
"user-1",
[STEPS_READING],
{ deferRollup: true },
);
expect(updateMock).not.toHaveBeenCalled();
expect(createManyMock).toHaveBeenCalledTimes(1);
expect(imported).toBe(1);
});
});
+94 -2
View File
@@ -525,7 +525,12 @@ export async function upsertGoogleHealthMeasurements(
externalId: string;
sleepStage: GoogleHealthMeasurementUpsert["sleepStage"];
}> = [];
const toUpdate: Array<{ id: string; r: GoogleHealthMeasurementUpsert }> = [];
const toUpdate: Array<{
id: string;
r: GoogleHealthMeasurementUpsert;
/** Set when a key-format migration re-keys the row in place (see below). */
reKeyTo?: string;
}> = [];
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
// A batch can carry the same (type, externalId) twice (an overlap re-fetch);
@@ -577,6 +582,91 @@ export async function upsertGoogleHealthMeasurements(
}
}
// ── KEY-FORMAT MIGRATION RESCUE ──────────────────────────────────────────
// measurements carries a SECOND full unique index — the natural key
// `(user_id, type, measured_at, source, sleep_stage)` NULLS NOT DISTINCT
// (migration 0055) — and it covers tombstoned rows too. When an externalId
// FORMAT changes (the v1.28.18 sleep-anchor fix re-keyed every segment), a
// re-fetched night's fresh rows carry NEW externalIds but the SAME natural
// key as the rows the replace-by-window sweep just tombstoned: the
// externalId probe finds nothing, the insert collides with the tombstone on
// the natural index, and `skipDuplicates` drops it SILENTLY — old row gone,
// new row never written (live incident: a full-history re-sync erased a
// user's sleep). So every planned CREATE gets a second probe by natural
// key; a match — live or tombstoned — is UPDATED in place instead: fresh
// value, the NEW externalId (the migration itself), `deletedAt: null`.
if (toCreate.length > 0) {
try {
const rescued = new Set<number>();
const naturalKeyOf = (
type: string,
measuredAt: Date,
stage: GoogleHealthMeasurementUpsert["sleepStage"],
): string => `${type}|${measuredAt.getTime()}|${stage ?? ""}`;
for (let i = 0; i < toCreate.length; i += GOOGLE_HEALTH_CREATE_CHUNK) {
const chunk = toCreate.slice(i, i + GOOGLE_HEALTH_CREATE_CHUNK);
const rows = await prisma.measurement.findMany({
where: {
userId,
source: "GOOGLE_HEALTH",
type: { in: [...new Set(chunk.map((c) => c.type))] },
measuredAt: { in: chunk.map((c) => c.measuredAt) },
},
select: {
id: true,
type: true,
measuredAt: true,
sleepStage: true,
},
});
const byNaturalKey = new Map(
rows.map((row) => [
naturalKeyOf(
row.type,
row.measuredAt,
row.sleepStage as GoogleHealthMeasurementUpsert["sleepStage"],
),
row.id,
]),
);
for (let j = 0; j < chunk.length; j++) {
const c = chunk[j];
const id = byNaturalKey.get(
naturalKeyOf(c.type, c.measuredAt, c.sleepStage),
);
if (id) {
rescued.add(i + j);
toUpdate.push({
id,
r: {
type: c.type,
value: c.value,
unit: c.unit,
measuredAt: c.measuredAt,
externalId: c.externalId,
sleepStage: c.sleepStage,
},
reKeyTo: c.externalId,
});
}
}
}
if (rescued.size > 0) {
for (let i = toCreate.length - 1; i >= 0; i--) {
if (rescued.has(i)) toCreate.splice(i, 1);
}
}
} catch (err) {
// Best-effort: an unresolved natural-key twin only means the insert is
// dropped by `skipDuplicates` as before — hold the watermark so the
// next tick retries the rescue rather than losing the rows for good.
getEvent()?.addWarning(
`google-health: natural-key rescue probe failed: ${err}`,
);
noteHardFailure("measurements:rescue");
}
}
let imported = 0;
// Fresh inserts: chunked `createMany` (server-owned rows, field-by-field).
@@ -619,7 +709,7 @@ export async function upsertGoogleHealthMeasurements(
// `syncVersion`. `deletedAt: null` rides along unconditionally — a no-op on
// a live row, a deliberate RESURRECTION on a tombstoned one (Google is the
// source of truth for its own rows; see TOMBSTONES RESURRECT above).
for (const { id, r } of toUpdate) {
for (const { id, r, reKeyTo } of toUpdate) {
try {
await prisma.measurement.update({
where: { id },
@@ -629,6 +719,8 @@ export async function upsertGoogleHealthMeasurements(
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
deletedAt: null,
// Key-format migration: adopt the fresh externalId in place.
...(reKeyTo ? { externalId: reKeyTo } : {}),
// Surface the server-side mutation to the iOS LWW reconciler.
syncVersion: { increment: 1 },
},
+5 -5
View File
@@ -83,7 +83,7 @@ import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { stripJsonFences } from "@/lib/insights/status-shared";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { annotate } from "@/lib/logging/context";
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 } from "@/lib/insights/briefing-failure-marker";
@@ -198,7 +198,7 @@ async function rerollBriefingParagraph(args: {
// Seedless on purpose: the seed would pin the same phrasing, which is
// exactly what we are varying. Higher temperature for prose variety.
temperature: BRIEFING_REROLL_TEMPERATURE,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not clipped at the client's 60 s default mid-stream.
// v1.25 — honours the per-user response-timeout setting (see caller).
@@ -726,7 +726,7 @@ export async function generateComprehensiveInsight(
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
// generation is not clipped at the client's 60 s default mid-stream.
// v1.25 — honours the per-user response-timeout setting.
@@ -802,7 +802,7 @@ export async function generateComprehensiveInsight(
"The previous reply could not be parsed as a JSON object.",
)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,
@@ -848,7 +848,7 @@ export async function generateComprehensiveInsight(
system: systemPrompt,
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
maxTokens: resolveInsightsMaxTokens(),
// v1.21.5 — wider upstream budget; see the first generation call.
// v1.25 — honours the per-user response-timeout setting.
timeoutMs: effectiveTimeoutMs,