From 915d5dea3ce7783ec81ec2b1da49e99e1a8eb83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 15:56:15 +0200 Subject: [PATCH 1/4] Say thank you where people can see it The contributor graph has carried every outside author correctly, because fork pull requests are merged as merge commits and the authorship survives onto main. But nobody reads a graph tab. The front page now names the practice and renders the graph inline, so a first-time contributor sees their own avatar on the page they landed on. The avatar strip comes from a third-party renderer, the same class of service as the four shields badges already at the top, and GitHub proxies it so no visitor address reaches that host. It is generated rather than hand-written on purpose: a hand-kept list is the kind of thing that goes stale quietly. --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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. From f2c7ed38a4082d2ad3df865def605d1569e90eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 16:07:08 +0200 Subject: [PATCH 2/4] test(a11y): match only the visible copy of a painted marker The login case failed with a strict-mode violation: the marker it waits for resolved to two elements, one of them hidden. Two copies of a page coexist for a moment during a route transition, so a bare attribute locator can see both, and Playwright refuses an ambiguous match rather than picking one. The run had passed on the same page code minutes earlier, which is what a race looks like. Four locators in this file already carry the :visible filter for that reason. Seven did not, and had no .first() either, so every one of them was exposed to the same failure on a slow enough runner. They carry it now, which leaves the assertion aimed at the copy a reader would see. --- e2e/a11y.spec.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) 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( From f4c90dc13486337c55806c9c959af8a877f01d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 16:17:10 +0200 Subject: [PATCH 3/4] fix(ai): pick the admin client from the configured base URL The operator's global provider always built an OpenAI client, so an admin who saved an Anthropic key and https://api.anthropic.com/v1 (a host the settings allowlist accepts) had every AI call fail: an OpenAI-shaped body posted at Anthropic's Messages API, with nothing in the error pointing at the mismatch. Route by hostname instead. api.anthropic.com and any subdomain of anthropic.com get the same AnthropicClient the per-user ANTHROPIC arm builds, with the same Claude model default; every other host keeps the OpenAI client, which is what an OpenAI-compatible endpoint needs. The match parses the URL rather than searching the string, so a lookalike host cannot claim the Anthropic wire, and an unparseable value keeps the OpenAI path it had before. The chain tag stays admin-openai: it names the slot, not the wire, and it is persisted in user chains and pinned by the OpenAPI enum. The wire is already visible in the instance type the wide events read. --- .../ai/__tests__/provider-admin-host.test.ts | 220 ++++++++++++++++++ src/lib/ai/provider.ts | 51 +++- 2 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/lib/ai/__tests__/provider-admin-host.test.ts 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, }); } From b34e911c8e9f5f20a42a2cb73355e8cb4d6d31b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 16:17:17 +0200 Subject: [PATCH 4/4] docs(admin): say which providers the server-key base URL accepts The operator card offered a free-text base URL with an OpenAI placeholder and no word about what else it takes, so an Anthropic endpoint looked unsupported even though the field accepts that host. One line beside the existing hints, in all seven locales, plus the changelog entry. --- CHANGELOG.md | 15 +++++++++++++++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + src/components/admin/ai-server-key-section.tsx | 5 +++++ 9 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2693f1f..978eae4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [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. + ## [1.38.3] — 2026-09-02 An account with two-factor can be deleted from the phone, and every data 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")}