mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
c2ea07b0bc
commit
f4c90dc134
2 files changed
+270
-1
No files matched your search
@@ -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