mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #897 from MBombeck/fix/admin-provider-host
fix(ai): send the admin key to the provider its base URL names
This commit is contained in:
11 files changed
+297
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 키",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
<p>{t("admin.aiServerKey.baseUrlHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -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<typeof import("undici")>();
|
||||
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<string, unknown> = {}) {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+50
-1
@@ -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<AIProvider> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user