diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b96506e..4303ae830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Fixed + +- **The AI server key reaches Anthropic when the base URL points there.** An + operator who filled the server key with an Anthropic key and + `https://api.anthropic.com/v1` got a failure on every AI call and nothing + saying why: the request was built for OpenAI's API and posted at Anthropic's, + which speaks a different shape. The base URL now picks the client. Anthropic's + host, and any subdomain of it, is called on Anthropic's Messages API and + defaults to a Claude model; every other host keeps the OpenAI-compatible API + it had before, which is also the right one for a local server or a gateway. A + base URL that is not a URL at all behaves as it did. The operator card now + says which providers the field accepts. Found in a fork by @sweidinger. + ### Security - **The published image no longer ships four flagged dependency versions.** diff --git a/README.md b/README.md index 3576e1a60..8fd6bbe39 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,21 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only. +## Thanks + +HealthLog is built in the open and people keep making it better. Alongside +bug reports, setup questions and the ideas that turned into features, these +people have sent code that shipped: + + + Contributors to HealthLog + + +Every merged pull request keeps its author's name on the commit, so the +[contributor graph](https://github.com/MBombeck/HealthLog/graphs/contributors) +is the full list. Release notes name the people whose work is in that release, +including anyone who found the defect without sending the fix. + ## License HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license. diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 37b124f6d..cbdf84d14 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -745,7 +745,8 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ { name: "/insights/workouts/:id detail", path: `/insights/workouts/${A11Y_WORKOUT_ID}`, - painted: (page) => page.locator('[data-slot="workout-detail-header"]'), + painted: (page) => + page.locator('[data-slot="workout-detail-header"]:visible'), }, { // `coach-page` is the sizing wrapper around a ``, @@ -754,7 +755,9 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ name: "/coach", path: "/coach", painted: (page) => - page.locator('[data-slot="coach-conversation"][data-variant="page"]'), + page.locator( + '[data-slot="coach-conversation"][data-variant="page"]:visible', + ), }, ]; @@ -788,7 +791,7 @@ const RECORD_ROUTES: readonly RouteCase[] = [ { name: "/labs", path: "/labs", - painted: (page) => page.locator('[data-slot="lab-list"]'), + painted: (page) => page.locator('[data-slot="lab-list"]:visible'), }, { name: "/documents compact panel", @@ -842,7 +845,7 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [ // status pill later, so the panel's presence is the honest ceiling here. name: "/settings/integrations", path: "/settings/integrations", - painted: (page) => page.locator('[data-slot="connections-panel"]'), + painted: (page) => page.locator('[data-slot="connections-panel"]:visible'), }, { // The heading comes from `` and is on screen while every card @@ -850,12 +853,12 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [ // overview and only exists once its query has answered. name: "/admin overview", path: "/admin", - painted: (page) => page.locator('[data-slot="admin-audit-rows"]'), + painted: (page) => page.locator('[data-slot="admin-audit-rows"]:visible'), }, { name: "/admin/system-status", path: "/admin/system-status", - painted: (page) => page.locator('[data-slot="system-status-grid"]'), + painted: (page) => page.locator('[data-slot="system-status-grid"]:visible'), }, { name: "/admin/users", @@ -877,7 +880,7 @@ test.describe("axe-core public surfaces", () => { // around whatever the segment is streaming, so it is satisfied // before the login card itself exists. The action block is the // page's own. - page.locator('[data-slot="login-actions"]'), + page.locator('[data-slot="login-actions"]:visible'), ); expectNoViolations(theme, "/auth/login", blocking); }); @@ -937,7 +940,9 @@ test.describe("axe-core authenticated route and state matrix", () => { await test.step(`${theme} /labs open OCR picker dialog`, async () => { await page.goto("/labs", { waitUntil: "domcontentloaded" }); - await expect(page.locator('[data-slot="lab-list"]')).toBeVisible({ + await expect( + page.locator('[data-slot="lab-list"]:visible'), + ).toBeVisible({ timeout: 15_000, }); await openMenu( diff --git a/messages/de.json b/messages/de.json index 3445309ab..ae260abd6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6473,6 +6473,7 @@ "description": "Ein Betreiber-Schlüssel für Konten, die keinen eigenen hinterlegt haben.", "byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang — der Server-Schlüssel ist die letzte Stufe der Provider-Kette.", "consentHint": "Anfragen über den Server-Schlüssel erfordern die aktive KI-Einwilligung der Person; ohne Einwilligungsbeleg wird nichts gesendet.", + "baseUrlHint": "Die Basis-URL bestimmt die API: eine Anthropic-URL wie https://api.anthropic.com/v1 wird erkannt, jeder andere Host wird über die OpenAI-kompatible API angesprochen.", "statusConfigured": "Eingerichtet", "statusNotConfigured": "Nicht eingerichtet", "keyLabel": "API-Schlüssel", diff --git a/messages/en.json b/messages/en.json index 42b53a7cf..bfbb0ab32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6473,6 +6473,7 @@ "description": "An operator key for accounts that have not configured one of their own.", "byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence — the server key is the last step in the provider chain.", "consentHint": "Requests over the server key require the user's active AI consent; without a consent receipt nothing is sent.", + "baseUrlHint": "The base URL selects the API: an Anthropic URL such as https://api.anthropic.com/v1 is understood, any other host is called on the OpenAI-compatible API.", "statusConfigured": "Configured", "statusNotConfigured": "Not configured", "keyLabel": "API key", diff --git a/messages/es.json b/messages/es.json index f24a91a82..2ecddf478 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6473,6 +6473,7 @@ "description": "Una clave del operador para las cuentas que no tienen una propia.", "byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad — la clave del servidor es el último paso de la cadena de proveedores.", "consentHint": "Las solicitudes con la clave del servidor requieren el consentimiento de IA activo del usuario; sin un recibo de consentimiento no se envía nada.", + "baseUrlHint": "La URL base determina la API: se reconoce una URL de Anthropic como https://api.anthropic.com/v1, cualquier otro host se llama a través de la API compatible con OpenAI.", "statusConfigured": "Configurada", "statusNotConfigured": "Sin configurar", "keyLabel": "Clave de API", diff --git a/messages/fr.json b/messages/fr.json index 36901d982..63b62b5ff 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6473,6 +6473,7 @@ "description": "Une clé de l'opérateur pour les comptes qui n'en ont pas configuré une.", "byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité — la clé du serveur est la dernière étape de la chaîne de fournisseurs.", "consentHint": "Les requêtes via la clé du serveur nécessitent le consentement IA actif de l'utilisateur ; sans justificatif de consentement, rien n'est envoyé.", + "baseUrlHint": "L'URL de base détermine l'API : une URL Anthropic telle que https://api.anthropic.com/v1 est reconnue, tout autre hôte est appelé via l'API compatible OpenAI.", "statusConfigured": "Configurée", "statusNotConfigured": "Non configurée", "keyLabel": "Clé API", diff --git a/messages/it.json b/messages/it.json index ee272c0cb..3cc1dd08e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6473,6 +6473,7 @@ "description": "Una chiave dell'operatore per gli account che non ne hanno una propria.", "byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza — la chiave del server è l'ultimo passo della catena dei provider.", "consentHint": "Le richieste tramite la chiave del server richiedono il consenso IA attivo dell'utente; senza una ricevuta di consenso non viene inviato nulla.", + "baseUrlHint": "L'URL di base determina l'API: un URL Anthropic come https://api.anthropic.com/v1 viene riconosciuto, qualsiasi altro host viene chiamato tramite l'API compatibile con OpenAI.", "statusConfigured": "Configurata", "statusNotConfigured": "Non configurata", "keyLabel": "Chiave API", diff --git a/messages/ko.json b/messages/ko.json index ab9e42553..062b3e109 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6473,6 +6473,7 @@ "description": "자기 키를 설정하지 않은 계정을 위한 운영자 키예요.", "byokHint": "사용자 본인 키(BYOK, ChatGPT 로그인, 로컬 제공자)가 항상 우선해요 — 서버 키는 제공자 체인의 마지막 단계예요.", "consentHint": "서버 키로 나가는 요청에는 사용자의 유효한 AI 동의가 필요해요. 동의 기록이 없으면 아무것도 전송하지 않아요.", + "baseUrlHint": "기본 URL이 API를 결정합니다. https://api.anthropic.com/v1 같은 Anthropic 주소는 인식되며, 그 밖의 호스트는 OpenAI 호환 API로 호출됩니다.", "statusConfigured": "설정됨", "statusNotConfigured": "설정 안 됨", "keyLabel": "API 키", diff --git a/messages/pl.json b/messages/pl.json index b95837dd1..de783600b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6473,6 +6473,7 @@ "description": "Klucz operatora dla kont, które nie mają własnego.", "byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo — klucz serwera jest ostatnim ogniwem łańcucha dostawców.", "consentHint": "Żądania przez klucz serwera wymagają aktywnej zgody użytkownika na AI; bez potwierdzenia zgody nic nie jest wysyłane.", + "baseUrlHint": "Adres bazowy decyduje o API: adres Anthropic taki jak https://api.anthropic.com/v1 jest rozpoznawany, każdy inny host jest wywoływany przez API zgodne z OpenAI.", "statusConfigured": "Skonfigurowany", "statusNotConfigured": "Nieskonfigurowany", "keyLabel": "Klucz API", diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index 9fa09b63e..d9f0e6f1f 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -34,6 +34,10 @@ import { PasswordInput } from "./_shared"; * key here never silently forwards anyone's health data. * - The base URL is locked server-side to HTTPS + a hostname allowlist * (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it). + * - The base URL also picks the wire: an `anthropic.com` host is called on + * Anthropic's Messages API, every other host on the OpenAI-compatible one + * (`resolveAdminProvider` in `src/lib/ai/provider.ts`). `baseUrlHint` is + * that sentence for the operator. */ interface AiServerKeyResponse { @@ -113,6 +117,7 @@ export function AiServerKeySection() {

{t("admin.aiServerKey.byokHint")}

{t("admin.aiServerKey.consentHint")}

+

{t("admin.aiServerKey.baseUrlHint")}

diff --git a/src/lib/ai/__tests__/provider-admin-host.test.ts b/src/lib/ai/__tests__/provider-admin-host.test.ts new file mode 100644 index 000000000..d3ee44003 --- /dev/null +++ b/src/lib/ai/__tests__/provider-admin-host.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Client selection for the operator's global AI provider. + * + * The claim this file keeps true: the admin base URL decides which wire the + * operator's key rides. An Anthropic host gets the Anthropic Messages wire, + * everything else keeps the OpenAI chat-completions wire — including an + * OpenAI-compatible endpoint the operator allowlisted, an unset base URL and + * a value that does not parse as a URL. Every case is asserted at the wire: + * the test resolves a provider and runs a completion through a stubbed + * transport, so what is checked is the request that would leave the process. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((v: string) => `decrypted:${v}`), + encrypt: vi.fn((v: string) => `encrypted:${v}`), +})); +vi.mock("@/lib/ai/codex-oauth", () => ({ + refreshDeviceTokens: vi.fn(), + encryptCodexCreds: vi.fn(), + decryptCodexCreds: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => undefined), +})); +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +import { resolveProvider } from "../provider"; +import { singleUserTurn } from "../types"; +import { prisma } from "@/lib/db"; + +/** A user with nothing configured, so resolution falls through to the admin key. */ +function emptyUserRow() { + return { + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiCompatBaseUrl: null, + aiCompatKeyEncrypted: null, + aiCompatModel: null, + aiProviderChain: null, + useCentralCodex: false, + codexConnectionStatus: null, + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + } as never; +} + +function adminSettings(overrides: Record = {}) { + return { + id: "singleton", + adminAiKeyEncrypted: "enc-admin-key", + adminAiModel: null, + adminAiBaseUrl: null, + ...overrides, + } as never; +} + +/** + * One transport stub for both wires: the OpenAI body reads `choices`, the + * Anthropic body reads `content`, and each client ignores the other's field. + */ +function stubTransport() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"ok":true}' } }], + usage: { total_tokens: 1 }, + content: [{ type: "text", text: '{"ok":true}' }], + }), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const probe = () => + singleUserTurn({ system: "s", user: "u", responseFormat: "json" }); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue(emptyUserRow()); +}); + +describe("the admin base URL picks the client", () => { + it("sends an Anthropic base URL down the Anthropic wire", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue( + adminSettings({ + adminAiBaseUrl: "https://api.anthropic.com/v1", + adminAiModel: "claude-sonnet-4-6", + }), + ); + + const provider = await resolveProvider("u-1"); + expect(provider.type).toBe("anthropic"); + + const result = await provider.generateCompletion(probe()); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.anthropic.com/v1/messages"); + expect(init.headers["x-api-key"]).toBe("decrypted:enc-admin-key"); + expect(JSON.parse(init.body).model).toBe("claude-sonnet-4-6"); + expect(result.providerType).toBe("anthropic"); + }); + + it("treats a subdomain of anthropic.com as Anthropic, case-insensitively", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue( + adminSettings({ + adminAiBaseUrl: "https://API.EU.Anthropic.COM/v1", + adminAiModel: "claude-sonnet-4-6", + }), + ); + + const provider = await resolveProvider("u-1"); + expect(provider.type).toBe("anthropic"); + + await provider.generateCompletion(probe()); + // The base URL rides through verbatim; only the host MATCH folds case. + expect(fetchMock.mock.calls[0][0]).toBe( + "https://API.EU.Anthropic.COM/v1/messages", + ); + }); + + it("keeps the OpenAI wire for the OpenAI host", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue( + adminSettings({ + adminAiBaseUrl: "https://api.openai.com/v1", + adminAiModel: "gpt-4o-mini", + }), + ); + + const provider = await resolveProvider("u-1"); + expect(provider.type).toBe("admin-key"); + + await provider.generateCompletion(probe()); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.openai.com/v1/chat/completions"); + expect(init.headers.Authorization).toBe("Bearer decrypted:enc-admin-key"); + expect(JSON.parse(init.body).model).toBe("gpt-4o-mini"); + }); + + it("keeps the OpenAI wire for an OpenAI-compatible endpoint", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue( + adminSettings({ + adminAiBaseUrl: "https://llm.example.com/v1", + adminAiModel: "llama-3.1-70b", + }), + ); + + const provider = await resolveProvider("u-1"); + expect(provider.type).toBe("admin-key"); + + await provider.generateCompletion(probe()); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://llm.example.com/v1/chat/completions", + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe( + "llama-3.1-70b", + ); + }); + + it("keeps the OpenAI default when no base URL is set", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(adminSettings()); + + const provider = await resolveProvider("u-1"); + expect(provider.type).toBe("admin-key"); + + await provider.generateCompletion(probe()); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.openai.com/v1/chat/completions"); + expect(JSON.parse(init.body).model).toBe("gpt-4o"); + }); + + it("keeps the OpenAI path for a base URL that does not parse", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue( + adminSettings({ adminAiBaseUrl: "api.anthropic.com" }), + ); + + const provider = await resolveProvider("u-1"); + expect(provider.type).toBe("admin-key"); + }); + + it("defaults the model to a Claude model on the Anthropic wire", async () => { + const fetchMock = stubTransport(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue( + adminSettings({ adminAiBaseUrl: "https://api.anthropic.com/v1" }), + ); + + const provider = await resolveProvider("u-1"); + await provider.generateCompletion(probe()); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe( + "claude-sonnet-4-6", + ); + }); +}); diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 33e2228fb..017e4b4fe 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -142,16 +142,65 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { } } +/** + * True when a base URL addresses Anthropic's API: the host itself or any + * subdomain of `anthropic.com`, case-insensitively. Parsed rather than + * substring-matched, so `https://anthropic.com.attacker.example/v1` and + * `https://evil.example/?x=api.anthropic.com` both answer false. + * + * An unparseable value answers false and keeps the OpenAI path, which is the + * behaviour that value had before this function existed. + */ +function isAnthropicBaseUrl(baseUrl: string): boolean { + let host: string; + try { + host = new URL(baseUrl).hostname.toLowerCase(); + } catch { + return false; + } + return host === "anthropic.com" || host.endsWith(".anthropic.com"); +} + +/** + * The operator's global provider — one key, one model, one base URL in the + * admin settings, serving every user who has configured nothing themselves. + * + * The base URL decides the wire. An operator who fills these fields with an + * Anthropic endpoint and an Anthropic key used to get OpenAI-shaped requests + * posted at Anthropic, which fails every call with nothing pointing at the + * cause; the host check routes them to the same `AnthropicClient` the + * per-user `ANTHROPIC` arm builds. Every other host stays on `OpenAIClient`, + * which is also the right client for an OpenAI-compatible endpoint (a local + * server, a gateway) — the admin base URL is host-allowlisted at write time + * in `/api/admin/ai-settings`. + * + * The chain still reports this entry as `admin-openai` whichever client it + * builds. That tag names the SLOT — "the operator's shared credential, last + * in the chain" — not the wire: it is persisted in `User.aiProviderChain`, + * offered in the chain editor, and pinned by the OpenAPI enum, so splitting + * it per wire would rewrite stored chains and widen a structural allowlist to + * express something the runtime provider type (`admin-key` / `anthropic` on + * the instance, which is what the wide events and the budget classifier read) + * already carries. + */ async function resolveAdminProvider(): Promise { const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" }, }); if (settings?.adminAiKeyEncrypted) { + const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1"; + if (isAnthropicBaseUrl(baseUrl)) { + return new AnthropicClient({ + apiKey: decrypt(settings.adminAiKeyEncrypted), + model: settings.adminAiModel ?? "claude-sonnet-4-6", + baseUrl, + }); + } return new OpenAIClient({ apiKey: decrypt(settings.adminAiKeyEncrypted), model: settings.adminAiModel ?? "gpt-4o", - baseUrl: settings.adminAiBaseUrl ?? "https://api.openai.com/v1", + baseUrl, }); }