From fb9cd9b965505da30090302dacd9e63d0ac20736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 17:19:21 +0200 Subject: [PATCH] refactor(ai): move the stored-key opener and the Anthropic host check out of provider.ts Both are self-contained; provider.ts stays within its size baseline. --- src/app/api/admin/provider-health/route.ts | 2 +- src/lib/ai/anthropic-host.ts | 18 +++++++ src/lib/ai/provider.ts | 50 +++---------------- src/lib/ai/reasoning/controls.ts | 3 +- src/lib/ai/stored-key.ts | 21 ++++++++ .../background-reasoning-wire.test.ts | 1 - 6 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 src/lib/ai/anthropic-host.ts create mode 100644 src/lib/ai/stored-key.ts diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 38a761efe..341164e1c 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -10,7 +10,7 @@ import { type ModelListing, } from "@/lib/ai/model-availability"; import { decrypt } from "@/lib/crypto"; -import { isAnthropicBaseUrl } from "@/lib/ai/provider"; +import { isAnthropicBaseUrl } from "@/lib/ai/anthropic-host"; import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities"; import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage"; import { diff --git a/src/lib/ai/anthropic-host.ts b/src/lib/ai/anthropic-host.ts new file mode 100644 index 000000000..6a9b04eed --- /dev/null +++ b/src/lib/ai/anthropic-host.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ +export 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"); +} diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 5343559dc..d589685ce 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -3,7 +3,8 @@ import { readOperatorRow } from "@/lib/settings/operator-row"; import { bindResponseTimeout } from "./effective-timeout"; import { bindReasoningEffort } from "./reasoning-effort"; import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; +import { openStoredKey } from "./stored-key"; +import { isAnthropicBaseUrl } from "./anthropic-host"; import { CodexClient, resolveCodexVisionSlug } from "./codex-client"; import { OpenAIClient } from "./openai-client"; import { AnthropicClient } from "./anthropic-client"; @@ -34,7 +35,6 @@ import { } from "@/lib/sharing/provider-work-authority"; import { memoizePerRequest } from "@/lib/request-cache"; import { emitSignal } from "@/lib/logging/signal"; -import { getEvent } from "@/lib/logging/context"; import { acquireProviderTokenRefreshLock, PROVIDER_REFRESH_TRANSACTION_OPTIONS, @@ -371,22 +371,6 @@ function isOperatorOwned(row: { role: string } | null | undefined): boolean { return row?.role === "ADMIN"; } -/** - * A stored provider key, or null when it cannot be opened: a key id retired - * from `ENCRYPTION_KEYS`, a corrupt row. An unreadable key is an unconfigured - * provider, the same as no key at all. Its entry drops out of the chain, so a - * generation answers the no-provider refusal (422 `ai.provider.none`) - * instead of the decrypt's 500, and the operator sees the warning. - */ -function openStoredKey(encrypted: string): string | null { - try { - return decrypt(encrypted); - } catch { - getEvent()?.addWarning("ai: a stored provider key could not be opened"); - return null; - } -} - /** * v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's * three dedicated columns. The signature names every column this provider is @@ -479,8 +463,9 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { // Belt-and-braces: ignore any persisted `aiBaseUrl`. The column is // shared with LOCAL, so a stale LAN URL there would otherwise // redirect the user's OpenAI key to a private host. - if (!row.aiOpenaiKeyEncrypted) return null; - const apiKey = openStoredKey(row.aiOpenaiKeyEncrypted); + const apiKey = row.aiOpenaiKeyEncrypted + ? openStoredKey(row.aiOpenaiKeyEncrypted) + : null; if (apiKey === null) return null; return new OpenAIClient({ apiKey, @@ -496,25 +481,6 @@ 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. - */ -export 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. @@ -1381,8 +1347,7 @@ async function resolveProviderForType( } case "openai": { const enc = ctx.userRow?.aiOpenaiKeyEncrypted; - if (!enc) return null; - const apiKey = openStoredKey(enc); + const apiKey = enc ? openStoredKey(enc) : null; if (apiKey === null) return null; return new OpenAIClient({ apiKey, @@ -1392,8 +1357,7 @@ async function resolveProviderForType( } case "anthropic": { const enc = ctx.userRow?.aiAnthropicKeyEncrypted; - if (!enc) return null; - const apiKey = openStoredKey(enc); + const apiKey = enc ? openStoredKey(enc) : null; if (apiKey === null) return null; return new AnthropicClient({ apiKey, diff --git a/src/lib/ai/reasoning/controls.ts b/src/lib/ai/reasoning/controls.ts index a34655774..624d7055f 100644 --- a/src/lib/ai/reasoning/controls.ts +++ b/src/lib/ai/reasoning/controls.ts @@ -9,7 +9,8 @@ import { readOperatorRow } from "@/lib/settings/operator-row"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { memoizePerRequest } from "@/lib/request-cache"; -import { isAnthropicBaseUrl, probeProviderChain } from "@/lib/ai/provider"; +import { isAnthropicBaseUrl } from "@/lib/ai/anthropic-host"; +import { probeProviderChain } from "@/lib/ai/provider"; import { isOperatorFundedProvider } from "@/lib/ai/coach/budget"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; diff --git a/src/lib/ai/stored-key.ts b/src/lib/ai/stored-key.ts new file mode 100644 index 000000000..e4e6ad007 --- /dev/null +++ b/src/lib/ai/stored-key.ts @@ -0,0 +1,21 @@ +/** + * Opening a stored AI-provider key. + */ +import { decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; + +/** + * A stored provider key, or null when it cannot be opened: a key id retired + * from `ENCRYPTION_KEYS`, a corrupt row. An unreadable key is an unconfigured + * provider, the same as no key at all. Its entry drops out of the chain, so a + * generation answers the no-provider refusal (422 `ai.provider.none`) + * instead of the decrypt's 500, and the operator sees the warning. + */ +export function openStoredKey(encrypted: string): string | null { + try { + return decrypt(encrypted); + } catch { + getEvent()?.addWarning("ai: a stored provider key could not be opened"); + return null; + } +} diff --git a/src/lib/insights/__tests__/background-reasoning-wire.test.ts b/src/lib/insights/__tests__/background-reasoning-wire.test.ts index a135cbea1..aeb843364 100644 --- a/src/lib/insights/__tests__/background-reasoning-wire.test.ts +++ b/src/lib/insights/__tests__/background-reasoning-wire.test.ts @@ -48,7 +48,6 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProvider: vi.fn(), probeProviderPresence: vi.fn(async () => true), probeProviderChain: vi.fn(), - isAnthropicBaseUrl: () => false, })); vi.mock("@/lib/ai/capabilities/gate", () => ({