Merge origin/main into chore/scanning-sweep

This commit is contained in:
Marc-André Bombeck committed 2026-09-02 16:42:17 +02:00
commit 0635db898a
13 files changed
+323 -9

No files matched your search

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