mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
1182 lines
41 KiB
TypeScript
1182 lines
41 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";
|
|
import {
|
|
providerCredentialPolicy,
|
|
providerWorkAuthorityForRecord,
|
|
type ProviderCredentialPolicy,
|
|
} from "@/lib/sharing/provider-work-authority";
|
|
|
|
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;
|
|
aiCompatBaseUrl: string | null;
|
|
aiCompatKeyEncrypted: string | null;
|
|
aiCompatModel: string | 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
|
|
* allowed to read: the OpenAI key and `aiBaseUrl` are absent on purpose, so a
|
|
* future edit that wants to hand it either has to widen this shape first and
|
|
* answer for it.
|
|
*
|
|
* Returns null when the gateway cannot be addressed — no base URL, or no
|
|
* model anywhere. An unconfigured entry is SKIPPED by the chain runner rather
|
|
* than erroring, exactly like an `anthropic` entry with no key.
|
|
*
|
|
* Model resolution: `aiCompatModel` is the override; absent, the shared
|
|
* `aiModel` applies (a gateway usually routes the same model name the user
|
|
* already types for their other providers). Nothing is invented — with
|
|
* neither set there is no model to send, so the entry does not resolve.
|
|
*
|
|
* The key is OPTIONAL: a LAN LiteLLM without a master key needs no bearer,
|
|
* and the client omits the header entirely for an empty string. The base URL
|
|
* passed the SSRF floor at write time (`/api/user/ai-provider`); the client
|
|
* re-applies the Local provider's host policy at call time.
|
|
*/
|
|
function buildCompatProvider(row: {
|
|
aiModel: string | null;
|
|
aiCompatBaseUrl: string | null;
|
|
aiCompatKeyEncrypted: string | null;
|
|
aiCompatModel: string | null;
|
|
}): AIProvider | null {
|
|
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
|
|
if (!row.aiCompatBaseUrl || !model) return null;
|
|
return new OpenAIClient({
|
|
apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "",
|
|
model,
|
|
baseUrl: row.aiCompatBaseUrl,
|
|
providerType: "openai-compatible",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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_COMPATIBLE": {
|
|
// v1.33.1 (#470) — the gateway provider. Reads its own three columns
|
|
// and nothing else; the OpenAI arm below stays pinned.
|
|
return buildCompatProvider(row);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
}
|
|
|
|
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 authority = providerWorkAuthorityForRecord(userId);
|
|
if (providerCredentialPolicy(authority) === "deny") return new NoProvider();
|
|
|
|
const userRow = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
aiProvider: true,
|
|
aiModel: true,
|
|
aiBaseUrl: true,
|
|
aiAnthropicKeyEncrypted: true,
|
|
aiLocalKeyEncrypted: true,
|
|
aiOpenaiKeyEncrypted: true,
|
|
aiCompatBaseUrl: true,
|
|
aiCompatKeyEncrypted: true,
|
|
aiCompatModel: true,
|
|
managedProfileAt: true,
|
|
},
|
|
});
|
|
const policy = providerCredentialPolicy(
|
|
authority,
|
|
userRow?.managedProfileAt ?? null,
|
|
);
|
|
if (policy === "operator-default") return resolveAdminProvider();
|
|
|
|
// 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 authority = providerWorkAuthorityForRecord(userId);
|
|
if (providerCredentialPolicy(authority) === "deny") return [];
|
|
|
|
const userRow = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
aiProvider: true,
|
|
aiModel: true,
|
|
aiBaseUrl: true,
|
|
aiAnthropicKeyEncrypted: true,
|
|
aiLocalKeyEncrypted: true,
|
|
aiOpenaiKeyEncrypted: true,
|
|
aiCompatBaseUrl: true,
|
|
aiCompatKeyEncrypted: true,
|
|
aiCompatModel: true,
|
|
aiProviderChain: true,
|
|
useCentralCodex: true,
|
|
managedProfileAt: true,
|
|
},
|
|
});
|
|
const policy = providerCredentialPolicy(
|
|
authority,
|
|
userRow?.managedProfileAt ?? null,
|
|
);
|
|
if (policy === "operator-default") {
|
|
const provider = await resolveAdminProvider();
|
|
return provider.type === "none"
|
|
? []
|
|
: [{ providerType: "admin-openai", instance: provider }];
|
|
}
|
|
|
|
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,
|
|
policy,
|
|
});
|
|
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;
|
|
aiModel: string | null;
|
|
aiCompatBaseUrl: string | null;
|
|
aiCompatModel: 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,
|
|
policy: ProviderCredentialPolicy = "personal",
|
|
): boolean {
|
|
if (policy === "deny") return false;
|
|
if (policy === "operator-default") return adminKeyConfigured;
|
|
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 "openai-compatible":
|
|
// Presence mirrors `buildCompatProvider`: a gateway needs an address
|
|
// AND a model name; the key is optional.
|
|
if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) {
|
|
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_COMPATIBLE" &&
|
|
row.aiCompatBaseUrl &&
|
|
(row.aiCompatModel || row.aiModel)
|
|
) {
|
|
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 authority = providerWorkAuthorityForRecord(userId);
|
|
if (providerCredentialPolicy(authority) === "deny") return false;
|
|
const userRow = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
aiProvider: true,
|
|
aiProviderChain: true,
|
|
aiAnthropicKeyEncrypted: true,
|
|
aiLocalKeyEncrypted: true,
|
|
aiOpenaiKeyEncrypted: true,
|
|
aiBaseUrl: true,
|
|
aiCompatBaseUrl: true,
|
|
aiCompatModel: true,
|
|
aiModel: true,
|
|
codexConnectionStatus: true,
|
|
codexAccessTokenEncrypted: true,
|
|
codexRefreshTokenEncrypted: true,
|
|
useCentralCodex: true,
|
|
managedProfileAt: true,
|
|
},
|
|
});
|
|
if (!userRow) return false;
|
|
const policy = providerCredentialPolicy(authority, userRow.managedProfileAt);
|
|
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,
|
|
policy,
|
|
)
|
|
) {
|
|
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,
|
|
policy: ProviderCredentialPolicy = "personal",
|
|
): ProviderManagedBy | null {
|
|
if (policy === "deny") return null;
|
|
if (policy === "operator-default") {
|
|
return adminKeyConfigured ? "server" : 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 "openai-compatible":
|
|
// The gateway is the user's own egress to an endpoint they chose,
|
|
// like a BYO key — not the operator's, and not presumed on-host
|
|
// (LiteLLM / OpenRouter are usually remote).
|
|
if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) {
|
|
return "user";
|
|
}
|
|
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_COMPATIBLE" &&
|
|
row.aiCompatBaseUrl &&
|
|
(row.aiCompatModel || row.aiModel)
|
|
) {
|
|
return "user";
|
|
}
|
|
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 authority = providerWorkAuthorityForRecord(userId);
|
|
if (providerCredentialPolicy(authority) === "deny") {
|
|
return { aiAvailable: false, managedBy: null };
|
|
}
|
|
const userRow = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
aiProvider: true,
|
|
aiProviderChain: true,
|
|
aiAnthropicKeyEncrypted: true,
|
|
aiLocalKeyEncrypted: true,
|
|
aiOpenaiKeyEncrypted: true,
|
|
aiBaseUrl: true,
|
|
aiCompatBaseUrl: true,
|
|
aiCompatModel: true,
|
|
aiModel: true,
|
|
codexConnectionStatus: true,
|
|
codexAccessTokenEncrypted: true,
|
|
codexRefreshTokenEncrypted: true,
|
|
useCentralCodex: true,
|
|
managedProfileAt: true,
|
|
},
|
|
});
|
|
if (!userRow) return { aiAvailable: false, managedBy: null };
|
|
const policy = providerCredentialPolicy(authority, userRow.managedProfileAt);
|
|
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,
|
|
policy,
|
|
);
|
|
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;
|
|
aiCompatBaseUrl: string | null;
|
|
aiCompatKeyEncrypted: string | null;
|
|
aiCompatModel: string | null;
|
|
} | null;
|
|
policy: ProviderCredentialPolicy;
|
|
},
|
|
): Promise<AIProvider | null> {
|
|
if (ctx.policy === "deny") return null;
|
|
if (ctx.policy === "operator-default") {
|
|
return providerType === "admin-openai"
|
|
? await resolveAdminProvider()
|
|
: 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 "openai-compatible": {
|
|
// v1.33.1 (#470) — the user's gateway. Note what is NOT read here:
|
|
// `aiOpenaiKeyEncrypted` and `aiBaseUrl`. The gateway gets the
|
|
// credential the user issued for it, or none at all.
|
|
if (!ctx.userRow) return null;
|
|
return buildCompatProvider(ctx.userRow);
|
|
}
|
|
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;
|
|
// v1.33.1 (#470) — the gateway's own fields. Dedicated rather than shared
|
|
// with `baseUrl` / `openaiKey` for the same reason the columns are: the
|
|
// pinned OpenAI arm must not be able to see a user-supplied host, and the
|
|
// gateway must not be able to see the OpenAI key.
|
|
compatBaseUrl?: string | null;
|
|
compatKey?: string | null;
|
|
compatModel?: 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,
|
|
aiCompatBaseUrl: true,
|
|
aiCompatKeyEncrypted: true,
|
|
aiCompatModel: 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 "OPENAI_COMPATIBLE": {
|
|
// v1.33.1 (#470) — the gateway. Reads `aiCompatBaseUrl`, never the
|
|
// merged `baseUrl` above (which can carry a stale LOCAL URL) and never
|
|
// the OpenAI key. Same host policy as LOCAL: public always, private
|
|
// only when the operator allowlisted it.
|
|
const compatBaseUrl = (
|
|
override.compatBaseUrl ??
|
|
stored?.aiCompatBaseUrl ??
|
|
""
|
|
)
|
|
.toString()
|
|
.trim();
|
|
if (!compatBaseUrl) {
|
|
throw new AITestConfigError(
|
|
422,
|
|
"OpenAI-compatible provider requires a base URL",
|
|
);
|
|
}
|
|
if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) {
|
|
throw new AITestConfigError(
|
|
422,
|
|
"Base URL points to an internal/private host",
|
|
);
|
|
}
|
|
const compatModel =
|
|
(override.compatModel ?? stored?.aiCompatModel ?? "")
|
|
.toString()
|
|
.trim() || model;
|
|
if (!compatModel) {
|
|
throw new AITestConfigError(
|
|
422,
|
|
"OpenAI-compatible provider requires a model name",
|
|
);
|
|
}
|
|
const apiKey =
|
|
override.compatKey?.trim() ||
|
|
(stored?.aiCompatKeyEncrypted
|
|
? decrypt(stored.aiCompatKeyEncrypted)
|
|
: "");
|
|
return new OpenAIClient({
|
|
apiKey,
|
|
model: compatModel,
|
|
baseUrl: compatBaseUrl,
|
|
providerType: "openai-compatible",
|
|
});
|
|
}
|
|
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}`);
|
|
}
|
|
}
|