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:
Marc-André Bombeck authored and GitHub committed 2026-09-02 16:34:56 +02:00
commit 0e605b0698
11 files changed
+297 -1

No files matched your search

+15
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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 키",
+1
View File
@@ -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
View File
@@ -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,
});
}