mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
32 files changed
+930
-62
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user