Files
HealthLog/src/lib/ai/provider.ts
T
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00

917 lines
32 KiB
TypeScript

import type { AIProvider, CompletionResult } from "./types";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { CodexClient } from "./codex-client";
import { OpenAIClient } from "./openai-client";
import { AnthropicClient } from "./anthropic-client";
import { LocalOpenAICompatibleClient } from "./local-client";
import {
refreshDeviceTokens,
encryptCodexCreds,
decryptCodexCreds,
encryptAdminCodexCreds,
decryptAdminCodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import { parseProviderChain, type ProviderChainType } from "./provider-chain";
import type { ProviderChainResolved } from "./provider-runner";
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
class NoProvider implements AIProvider {
readonly type = "none" as const;
async generateCompletion(): Promise<CompletionResult> {
throw new Error(
"No AI provider configured. Connect ChatGPT or set an API key in settings.",
);
}
}
type UserAIRow = {
aiProvider: string | null;
aiModel: string | null;
aiBaseUrl: string | null;
aiAnthropicKeyEncrypted: string | null;
aiLocalKeyEncrypted: string | null;
aiOpenaiKeyEncrypted: string | null;
};
/**
* Build a provider from a user-level config row. Returns null if the row does
* not select a usable per-user provider (caller falls back to admin/codex).
*/
function buildUserProvider(row: UserAIRow): AIProvider | null {
const choice = row.aiProvider?.toUpperCase();
if (!choice) return null;
switch (choice) {
case "ANTHROPIC": {
if (!row.aiAnthropicKeyEncrypted) return null;
// Belt-and-braces: even if a stale `aiBaseUrl` from a prior LOCAL
// configuration survived in the row, refuse to forward an Anthropic
// key to it. Anthropic has no per-tenant base URL the UI exposes;
// the SDK default is correct.
return new AnthropicClient({
apiKey: decrypt(row.aiAnthropicKeyEncrypted),
model: row.aiModel ?? "claude-sonnet-4-6",
});
}
case "LOCAL": {
if (!row.aiBaseUrl) return null;
return new LocalOpenAICompatibleClient({
apiKey: row.aiLocalKeyEncrypted
? decrypt(row.aiLocalKeyEncrypted)
: null,
model: row.aiModel ?? "local-model",
baseUrl: row.aiBaseUrl,
});
}
case "OPENAI": {
// v1.4.3: user-level OpenAI key gets first crack — only fall back
// to the admin key if the user hasn't supplied their own. The
// model-default mirrors the admin path for consistency so a saved
// user "OPENAI" without an explicit model still produces an
// OpenAIClient with the current full-size default `gpt-4o`.
// 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;
return new OpenAIClient({
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
model: row.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
case "CHATGPT_OAUTH":
// Caller handles Codex OAuth via the dedicated branch; signal here.
return null;
default:
return null;
}
}
async function resolveAdminProvider(): Promise<AIProvider> {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings?.adminAiKeyEncrypted) {
return new OpenAIClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
model: settings.adminAiModel ?? "gpt-4o",
baseUrl: settings.adminAiBaseUrl ?? "https://api.openai.com/v1",
});
}
return new NoProvider();
}
/**
* Codex provider — device-code path.
*
* `codexAccessTokenEncrypted` stores an encrypted JSON blob with the
* OAuth access token AND the `chatgpt_account_id` claim from the
* id_token (the latter is mandatory in the `ChatGPT-Account-ID`
* header). `codexRefreshTokenEncrypted` continues to hold just the
* refresh token. See `codex-oauth.ts` for the storage codec.
*
* Old v1.4.7-v1.4.11 rows that stored a raw token string instead of
* the JSON envelope cannot be revived (the account id was never
* captured), so `decryptCodexCreds` returns null and we treat the
* connection as expired — the user re-runs the connect flow once.
*/
async function resolveCodexProvider(
userId: string,
): Promise<AIProvider | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
codexConnectionStatus: true,
},
});
if (
user?.codexConnectionStatus !== "connected" ||
!user.codexAccessTokenEncrypted ||
!user.codexRefreshTokenEncrypted
) {
return null;
}
const stored = decryptCodexCreds({
accessEncrypted: user.codexAccessTokenEncrypted,
refreshEncrypted: user.codexRefreshTokenEncrypted,
});
if (!stored) {
// Pre-v1.4.12 record without account_id — the token cannot
// satisfy the ChatGPT-Account-ID header. Mark the row as
// disconnected so the UI prompts the user to re-link.
await prisma.user.update({
where: { id: userId },
data: { codexConnectionStatus: "expired" },
});
return null;
}
let active = stored;
// Proactive refresh if the access token is within 5 min of expiry.
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const fresh = await refreshDeviceTokens(stored.refreshToken);
active = fresh;
const enc = encryptCodexCreds(fresh);
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
},
});
} catch {
// Fall through — CodexClient will trigger an on-401 refresh
// and persist there.
}
}
return new CodexClient({
accessToken: active.accessToken,
accountId: active.accountId,
onTokenRefresh: async () => {
const freshUser = await prisma.user.findUnique({
where: { id: userId },
select: {
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
},
});
if (
!freshUser?.codexAccessTokenEncrypted ||
!freshUser.codexRefreshTokenEncrypted
) {
throw new Error("No refresh token available");
}
const decoded = decryptCodexCreds({
accessEncrypted: freshUser.codexAccessTokenEncrypted,
refreshEncrypted: freshUser.codexRefreshTokenEncrypted,
});
if (!decoded) {
throw new Error("Codex token storage corrupt; user must re-link");
}
const fresh = await refreshDeviceTokens(decoded.refreshToken);
const enc = encryptCodexCreds(fresh);
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
},
});
return { accessToken: fresh.accessToken, accountId: fresh.accountId };
},
});
}
/**
* Operator-shared central Codex provider — the `admin-codex` chain entry.
*
* Mirrors `resolveCodexProvider` but reads the operator's singleton credential
* from `AppSettings.adminCodex*` (three dedicated encrypted columns) instead of
* the per-user `codex*Encrypted` columns, and writes any refreshed tokens back
* to the same singleton row. Returns null unless the operator has actually
* connected the central Codex (`adminCodexConnectionStatus === "connected"` with
* all three token columns present) — the per-user `useCentralCodex` opt-in is
* enforced by the caller before this is ever invoked.
*
* Never called with any user identity: the credential and every token refresh
* belong to the operator's account, so cost + rate limits land on the operator
* (billed against the operator cap, not the user plan).
*/
async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
adminCodexTokenExpiresAt: true,
},
});
if (
settings?.adminCodexConnectionStatus !== "connected" ||
!settings.adminCodexAccessTokenEncrypted ||
!settings.adminCodexRefreshTokenEncrypted ||
!settings.adminCodexAccountIdEncrypted
) {
return null;
}
const stored = decryptAdminCodexCreds({
accessEncrypted: settings.adminCodexAccessTokenEncrypted,
refreshEncrypted: settings.adminCodexRefreshTokenEncrypted,
accountIdEncrypted: settings.adminCodexAccountIdEncrypted,
expiresAt: settings.adminCodexTokenExpiresAt,
});
if (!stored) {
// Corrupt / undecryptable credential (e.g. a rotated-out key) — mark the
// singleton expired so the admin UI prompts a re-link. Never plaintext.
await prisma.appSettings.update({
where: { id: "singleton" },
data: { adminCodexConnectionStatus: "expired" },
});
return null;
}
let active = stored;
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const fresh = await refreshDeviceTokens(stored.refreshToken);
active = fresh;
const enc = encryptAdminCodexCreds(fresh);
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
},
});
} catch {
// Fall through — CodexClient triggers an on-401 refresh and persists via
// the callback below.
}
}
return new CodexClient({
accessToken: active.accessToken,
accountId: active.accountId,
onTokenRefresh: async () => {
const fresh = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
adminCodexTokenExpiresAt: true,
},
});
if (
!fresh?.adminCodexAccessTokenEncrypted ||
!fresh.adminCodexRefreshTokenEncrypted ||
!fresh.adminCodexAccountIdEncrypted
) {
throw new Error("No central-codex refresh token available");
}
const decoded = decryptAdminCodexCreds({
accessEncrypted: fresh.adminCodexAccessTokenEncrypted,
refreshEncrypted: fresh.adminCodexRefreshTokenEncrypted,
accountIdEncrypted: fresh.adminCodexAccountIdEncrypted,
expiresAt: fresh.adminCodexTokenExpiresAt,
});
if (!decoded) {
throw new Error(
"Central-codex token storage corrupt; re-link required",
);
}
const refreshed = await refreshDeviceTokens(decoded.refreshToken);
const enc = encryptAdminCodexCreds(refreshed);
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
adminCodexTokenExpiresAt: enc.expiresAt,
},
});
return {
accessToken: refreshed.accessToken,
accountId: refreshed.accountId,
};
},
});
}
/**
* Resolve the AI provider for a given user.
*
* Priority:
* 1. User selected ANTHROPIC / LOCAL with valid creds → that provider.
* 2. User selected CHATGPT_OAUTH (or no explicit choice but Codex tokens
* are connected) → Codex.
* 3. User selected OPENAI (or no creds for the chosen provider) → admin
* OpenAI key from app_settings.
* 4. Nothing configured → NoProvider().
*/
export async function resolveProvider(userId: string): Promise<AIProvider> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: {
aiProvider: true,
aiModel: true,
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
},
});
// 1. Per-user Anthropic / Local
if (userRow) {
const userProvider = buildUserProvider(userRow);
if (userProvider) return userProvider;
}
// 2. Codex OAuth (either explicitly selected or implicit fallback)
const explicitChoice = userRow?.aiProvider?.toUpperCase();
const tryCodex = explicitChoice === "CHATGPT_OAUTH" || !explicitChoice;
if (tryCodex) {
const codex = await resolveCodexProvider(userId);
if (codex) return codex;
}
// 3. Admin OpenAI key (also acts as fallback for user-OPENAI selection)
return resolveAdminProvider();
}
/**
* v1.4.16 phase B5b — resolve a chain of providers in priority order
* for the multi-provider fallback runner. Each entry pairs a logical
* `providerType` with a constructed `AIProvider` instance ready to
* accept `generateCompletion()` calls.
*
* Steps:
* 1. Read `User.aiProviderChain` (or `PROVIDER_CHAIN_DEFAULT` when
* null). Already sorted + deduplicated by `parseProviderChain`.
* 2. For each enabled entry, attempt to materialise the provider
* from the user's saved credentials. Drop entries that have no
* usable credential (e.g. chain says `anthropic` but
* `aiAnthropicKeyEncrypted` is null).
* 3. Returns the surviving array — possibly empty if the user has
* no configured providers anywhere. Caller raises 422 in that
* case.
*
* Reused by the regular insight-generate route (B5b) AND the v1.4.17
* feedback-attribution path (B5e) — both need the same resolution
* semantics. `resolveProvider()` keeps its single-result shape for
* the legacy `weight-status.ts` / `mood-status.ts` / etc. consumers
* that have not migrated to the chain runner yet.
*/
export async function resolveProviderChain(
userId: string,
): Promise<ProviderChainResolved[]> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: {
aiProvider: true,
aiModel: true,
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiProviderChain: true,
useCentralCodex: true,
},
});
const rawChain = userRow?.aiProviderChain ?? null;
const chain = parseProviderChain(rawChain).filter((e) => e.enabled);
const resolved: ProviderChainResolved[] = [];
for (const entry of chain) {
const instance = await resolveProviderForType(entry.providerType, {
userId,
userRow,
});
if (instance) {
resolved.push({ providerType: entry.providerType, instance });
}
}
// Operator-shared central Codex — appended LAST, and ONLY when the user opted
// in AND the operator has connected it. It is never part of the persisted
// chain (a persisted `admin-codex` entry resolves to null above), so this is
// the single place the opt-in gate is enforced. As a trailing fallback the
// user's own providers are tried first; a user with no personal provider
// resolves to `[admin-codex]` and is billed against the operator cap.
if (userRow?.useCentralCodex) {
const centralCodex = await resolveAdminCodexProvider();
if (centralCodex) {
resolved.push({ providerType: "admin-codex", instance: centralCodex });
}
}
return resolved;
}
/**
* Credential-presence subset of the `User` row that
* `userRowHasProviderCredential` evaluates. Mirrors the columns
* `resolveProviderChain` / `resolveProvider` read, but presence-only —
* no decrypt, no client construction, no token refresh.
*/
export interface ProviderCredentialRow {
aiProvider: string | null;
aiProviderChain: unknown;
aiAnthropicKeyEncrypted: string | null;
aiLocalKeyEncrypted: string | null;
aiOpenaiKeyEncrypted: string | null;
aiBaseUrl: string | null;
codexConnectionStatus: string | null;
codexAccessTokenEncrypted: string | null;
codexRefreshTokenEncrypted: string | null;
}
/**
* Cheap, synchronous "is any provider configured?" check over an already
* loaded credential row. Mirrors the resolution semantics of
* `resolveProviderChain` + the legacy `resolveProvider` fallback (the
* exact pair `generateComprehensiveInsight` uses to decide
* `skipped: no-provider`), but evaluates credential PRESENCE only —
* it never decrypts a key, constructs a client, or refreshes a Codex
* token. A `true` here can still resolve to a dead provider at call
* time (revoked key, unreachable local host); callers use it to decide
* whether a generation is worth attempting at all, not as a liveness
* probe.
*
* `adminKeyConfigured` is the presence of `appSettings.adminAiKeyEncrypted`
* — passed in so batch callers can read it once for a whole cohort.
*/
export function userRowHasProviderCredential(
row: ProviderCredentialRow,
adminKeyConfigured: boolean,
): boolean {
const codexConnected =
row.codexConnectionStatus === "connected" &&
!!row.codexAccessTokenEncrypted &&
!!row.codexRefreshTokenEncrypted;
const chain = parseProviderChain(row.aiProviderChain ?? null).filter(
(e) => e.enabled,
);
for (const entry of chain) {
switch (entry.providerType) {
case "codex":
if (codexConnected) return true;
break;
case "openai":
if (row.aiOpenaiKeyEncrypted) return true;
break;
case "anthropic":
if (row.aiAnthropicKeyEncrypted) return true;
break;
case "local":
if (row.aiBaseUrl) return true;
break;
case "admin-openai":
if (adminKeyConfigured) return true;
break;
}
}
// Legacy `resolveProvider()` fallback — only reached when the chain
// resolves empty. Mirrors buildUserProvider → codex → admin in order.
const choice = row.aiProvider?.toUpperCase();
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true;
if (choice === "LOCAL" && row.aiBaseUrl) return true;
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true;
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true;
return adminKeyConfigured;
}
/**
* Async single-user variant of `userRowHasProviderCredential`: two
* narrow presence reads (user credential columns + the admin key flag),
* no decrypt, no network. Used by read paths that only need to know
* whether a generation could ever produce provider-backed text (e.g.
* the dashboard snapshot's `briefingState: "no-provider"`).
*/
export async function hasAnyConfiguredProvider(
userId: string,
): Promise<boolean> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: {
aiProvider: true,
aiProviderChain: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiBaseUrl: true,
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
useCentralCodex: true,
},
});
if (!userRow) return false;
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminAiKeyEncrypted: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
},
});
if (userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted)) {
return true;
}
// A user with no personal provider can still be served by the operator's
// shared central Codex when they opted in and the operator connected it.
return userRow.useCentralCodex && appSettingsCentralCodexConnected(settings);
}
/**
* Presence-only check that the operator's central Codex is connected, over an
* already-loaded `AppSettings` subset. No decrypt, no network.
*/
function appSettingsCentralCodexConnected(
settings: {
adminCodexConnectionStatus: string | null;
adminCodexAccessTokenEncrypted: string | null;
adminCodexRefreshTokenEncrypted: string | null;
adminCodexAccountIdEncrypted: string | null;
} | null,
): boolean {
return (
settings?.adminCodexConnectionStatus === "connected" &&
!!settings.adminCodexAccessTokenEncrypted &&
!!settings.adminCodexRefreshTokenEncrypted &&
!!settings.adminCodexAccountIdEncrypted
);
}
/**
* Origin of the provider that would serve a given user, surfaced to
* clients that need to show or hide an AI surface (the iOS Coach gate).
*
* - "user" — a personal cloud credential resolves (Codex OAuth, or a
* BYO OpenAI / Anthropic key).
* - "local" — a per-user self-hosted base URL (Ollama / LM Studio).
* - "server" — no personal config, but the operator's admin-managed
* key serves the user. This is the case iOS #24 missed:
* the Coach works server-side yet the client saw `null`.
* - null — nothing configured anywhere; no provider can serve.
*
* Presence-only, mirroring `userRowHasProviderCredential`: it never
* decrypts a key, builds a client, or probes liveness.
*/
export type ProviderManagedBy = "user" | "local" | "server";
function resolveManagedByFromRow(
row: ProviderCredentialRow,
adminKeyConfigured: boolean,
): ProviderManagedBy | null {
const codexConnected =
row.codexConnectionStatus === "connected" &&
!!row.codexAccessTokenEncrypted &&
!!row.codexRefreshTokenEncrypted;
const chain = parseProviderChain(row.aiProviderChain ?? null).filter(
(e) => e.enabled,
);
for (const entry of chain) {
switch (entry.providerType) {
case "codex":
if (codexConnected) return "user";
break;
case "openai":
if (row.aiOpenaiKeyEncrypted) return "user";
break;
case "anthropic":
if (row.aiAnthropicKeyEncrypted) return "user";
break;
case "local":
if (row.aiBaseUrl) return "local";
break;
case "admin-openai":
if (adminKeyConfigured) return "server";
break;
}
}
// Legacy `resolveProvider()` fallback — only reached when the chain
// resolves empty. Mirrors buildUserProvider → codex → admin in order.
const choice = row.aiProvider?.toUpperCase();
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user";
if (choice === "LOCAL" && row.aiBaseUrl) return "local";
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user";
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user";
return adminKeyConfigured ? "server" : null;
}
/**
* Effective AI availability for a user: whether any provider can serve
* them, and which origin manages it. One narrow user read plus the
* shared admin-key flag — no decrypt, no network. Feeds the
* `GET /api/user/ai-provider` response so the iOS Coach surfaces even
* when the operator's admin-managed key is the only thing configured.
*/
export async function resolveProviderAvailability(
userId: string,
): Promise<{ aiAvailable: boolean; managedBy: ProviderManagedBy | null }> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: {
aiProvider: true,
aiProviderChain: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiBaseUrl: true,
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
useCentralCodex: true,
},
});
if (!userRow) return { aiAvailable: false, managedBy: null };
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
adminAiKeyEncrypted: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
},
});
const managedBy = resolveManagedByFromRow(
userRow,
!!settings?.adminAiKeyEncrypted,
);
if (managedBy !== null) return { aiAvailable: true, managedBy };
// The operator's shared central Codex is operator-managed egress ("server").
if (userRow.useCentralCodex && appSettingsCentralCodexConnected(settings)) {
return { aiAvailable: true, managedBy: "server" };
}
return { aiAvailable: false, managedBy: null };
}
/**
* Materialise a single chain entry. Returns null when the user lacks
* the matching credential — the chain runner skips null entries.
*/
async function resolveProviderForType(
providerType: ProviderChainType,
ctx: {
userId: string;
userRow: {
aiAnthropicKeyEncrypted: string | null;
aiLocalKeyEncrypted: string | null;
aiOpenaiKeyEncrypted: string | null;
aiBaseUrl: string | null;
aiModel: string | null;
} | null;
},
): Promise<AIProvider | null> {
switch (providerType) {
case "codex": {
return resolveCodexProvider(ctx.userId);
}
case "openai": {
const enc = ctx.userRow?.aiOpenaiKeyEncrypted;
if (!enc) return null;
return new OpenAIClient({
apiKey: decrypt(enc),
model: ctx.userRow?.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
case "anthropic": {
const enc = ctx.userRow?.aiAnthropicKeyEncrypted;
if (!enc) return null;
return new AnthropicClient({
apiKey: decrypt(enc),
model: ctx.userRow?.aiModel ?? "claude-sonnet-4-6",
});
}
case "local": {
if (!ctx.userRow?.aiBaseUrl) return null;
return new LocalOpenAICompatibleClient({
apiKey: ctx.userRow.aiLocalKeyEncrypted
? decrypt(ctx.userRow.aiLocalKeyEncrypted)
: null,
model: ctx.userRow.aiModel ?? "local-model",
baseUrl: ctx.userRow.aiBaseUrl,
});
}
case "admin-openai": {
const admin = await resolveAdminProvider();
return admin.type === "none" ? null : admin;
}
case "admin-codex":
// Never resolved from a persisted chain entry — the operator-shared
// central Codex is appended in `resolveProviderChain` ONLY behind the
// per-user `useCentralCodex` opt-in. Returning null here keeps a
// hand-crafted `admin-codex` chain entry from bypassing that gate.
return null;
default:
return null;
}
}
/**
* Override that the connection-test endpoint accepts so the user can
* verify a provider config they have NOT saved yet (dropdown change → test
* before commit). Plaintext keys never persist.
*/
export type AITestOverride = {
provider?: string | null;
model?: string | null;
baseUrl?: string | null;
anthropicKey?: string | null;
localKey?: string | null;
openaiKey?: string | null;
};
export class AITestConfigError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "AITestConfigError";
this.status = status;
}
}
/**
* Resolve the provider for `/api/ai/test`. Falls back to the persisted user
* config when the matching override field is empty, so a user with a stored
* Anthropic key can change the model in the dropdown and test it without
* re-typing the key. The base URL still goes through the SSRF guard.
*/
export async function resolveProviderForTest(
userId: string,
override: AITestOverride = {},
): Promise<AIProvider> {
const stored = await prisma.user.findUnique({
where: { id: userId },
select: {
aiProvider: true,
aiModel: true,
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
},
});
const provider = (override.provider ?? stored?.aiProvider ?? "")
.toString()
.trim()
.toUpperCase();
const model = (override.model ?? stored?.aiModel ?? "").toString().trim();
const baseUrl = (override.baseUrl ?? stored?.aiBaseUrl ?? "")
.toString()
.trim();
// Empty selection ("test my saved config" — the ai-section calls
// /api/ai/test with an empty body): resolve via the SAME path generation
// uses — the first enabled+credentialed entry of `resolveProviderChain`.
// The legacy codex→admin fallback used to probe a different provider than
// generation ran (observed: test→admin-key while generation→codex), so a
// green test gave no signal about whether overnight generation would
// work. Falling through to the chain keeps the two paths in lock-step.
if (!provider) {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain[0].instance;
// Chain empty (no enabled+credentialed entry) — mirror the regular
// single-provider resolution one more time before giving up.
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") return legacy;
throw new AITestConfigError(422, "No AI provider configured");
}
switch (provider) {
case "ANTHROPIC": {
const apiKey =
override.anthropicKey?.trim() ||
(stored?.aiAnthropicKeyEncrypted
? decrypt(stored.aiAnthropicKeyEncrypted)
: "");
if (!apiKey) {
throw new AITestConfigError(422, "Anthropic API key not configured");
}
// Anthropic has no UI base-URL input. Ignore the merged value to
// avoid leaking the key to a stale LOCAL URL still parked in the
// shared column.
return new AnthropicClient({
apiKey,
model: model || "claude-sonnet-4-6",
});
}
case "LOCAL": {
if (!baseUrl) {
throw new AITestConfigError(422, "Local provider requires a base URL");
}
// v1.18.7 (SECURITY LOW) — host allowlist (`true` = any private host;
// a comma-separated host list = only those) replaces the binary flag.
const allowPrivate = isLocalAiHostAllowed(baseUrl);
if (!allowPrivate && !isPublicUrl(baseUrl)) {
throw new AITestConfigError(
422,
"Base URL points to an internal/private host",
);
}
const apiKey =
override.localKey?.trim() ||
(stored?.aiLocalKeyEncrypted
? decrypt(stored.aiLocalKeyEncrypted)
: null);
return new LocalOpenAICompatibleClient({
apiKey,
model: model || "local-model",
baseUrl,
});
}
case "CHATGPT_OAUTH": {
const codex = await resolveCodexProvider(userId);
if (codex) return codex;
throw new AITestConfigError(422, "ChatGPT OAuth is not connected");
}
case "OPENAI": {
// Test path mirrors the persistent resolution: user key first,
// admin fallback if absent. We accept an `openaiKey` override
// from the test endpoint so a user can verify a not-yet-saved
// key dropdown change without persisting anything. Always use
// the canonical OpenAI base URL — the merged `baseUrl` may carry
// a stale LOCAL URL through `stored.aiBaseUrl`.
const userKey =
override.openaiKey?.trim() ||
(stored?.aiOpenaiKeyEncrypted
? decrypt(stored.aiOpenaiKeyEncrypted)
: "");
if (userKey) {
return new OpenAIClient({
apiKey: userKey,
model: model || "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
}
const admin = await resolveAdminProvider();
if (admin.type === "none") {
throw new AITestConfigError(
422,
"OpenAI key not configured (neither user nor admin)",
);
}
return admin;
}
default:
throw new AITestConfigError(422, `Unknown provider: ${provider}`);
}
}