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")}
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,
});
}