diff --git a/prisma/migrations/0026_user_ai_openai_key/migration.sql b/prisma/migrations/0026_user_ai_openai_key/migration.sql new file mode 100644 index 000000000..0974fcfad --- /dev/null +++ b/prisma/migrations/0026_user_ai_openai_key/migration.sql @@ -0,0 +1,14 @@ +-- v1.4.3: per-user OpenAI API key. +-- +-- Until this release the schema stored Anthropic and Local-provider API +-- keys but no field for the OPENAI provider, even though OPENAI was a +-- valid value in `users.ai_provider`. Users who picked it had no place +-- in the UI to drop their key and the runtime fell through to whichever +-- admin-level fallback was configured. This column is the missing slot. +-- +-- AES-256-GCM encrypted at the application layer (`src/lib/crypto.ts`) +-- before the value lands on disk. Plaintext keys never touch Postgres +-- backups. + +ALTER TABLE "users" + ADD COLUMN "ai_openai_key_encrypted" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e92255e7d..983dc4afc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,11 @@ model User { aiBaseUrl String? @map("ai_base_url") aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted") aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") + // v1.4.3: per-user OpenAI API key for the OPENAI provider — previously + // the schema only stored Anthropic and Local keys, leaving OPENAI without + // a credential surface. Encrypted with the active ENCRYPTION_KEYS entry + // before storage. + aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") // Onboarding onboardingCompletedAt DateTime? @map("onboarding_completed_at") diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 79057c945..ce98160e9 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -22,6 +22,7 @@ const overrideSchema = z baseUrl: z.string().url().max(2048).optional().nullable(), anthropicKey: z.string().min(1).max(500).optional().nullable(), localKey: z.string().min(1).max(500).optional().nullable(), + openaiKey: z.string().min(1).max(500).optional().nullable(), }) .strict(); diff --git a/src/app/api/auth/codex/authorize/route.ts b/src/app/api/auth/codex/authorize/route.ts index 0f186b3da..28333b3d2 100644 --- a/src/app/api/auth/codex/authorize/route.ts +++ b/src/app/api/auth/codex/authorize/route.ts @@ -5,6 +5,8 @@ import { generatePKCE, generateState, buildAuthorizationUrl, + CodexOAuthNotConfiguredError, + isCodexOAuthConfigured, } from "@/lib/ai/codex-oauth"; import { cookies } from "next/headers"; import { annotate } from "@/lib/logging/context"; @@ -12,6 +14,17 @@ import { annotate } from "@/lib/logging/context"; export const GET = apiHandler(async (_request: NextRequest) => { const { user } = await requireAuth(); + // Surface configuration errors as 503 instead of silently redirecting + // to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id` + // out of the authorize URL — the user landed on the chatgpt.com login + // page, no callback ever fired, and the connect button "did nothing". + if (!isCodexOAuthConfigured()) { + throw new HttpError( + 503, + "Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID", + ); + } + const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000); if (!rl.allowed) throw new HttpError(429, "Too many requests"); @@ -35,11 +48,21 @@ export const GET = apiHandler(async (_request: NextRequest) => { cookieStore.set("codex_verifier", verifier, cookieOptions); cookieStore.set("codex_state", state, cookieOptions); - const authUrl = buildAuthorizationUrl({ - codeChallenge: challenge, - state, - redirectUri, - }); + let authUrl: string; + try { + authUrl = buildAuthorizationUrl({ + codeChallenge: challenge, + state, + redirectUri, + }); + } catch (err) { + // Race between the env check above and the URL build — extremely + // unlikely but the typed error path is cheap insurance. + if (err instanceof CodexOAuthNotConfiguredError) { + throw new HttpError(503, err.message); + } + throw err; + } annotate({ action: { name: "codex.oauth.authorize" } }); return NextResponse.redirect(authUrl); diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts index 728c4f010..18383e515 100644 --- a/src/app/api/insights/settings/route.ts +++ b/src/app/api/insights/settings/route.ts @@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -28,6 +29,11 @@ export const GET = apiHandler(async () => { codexStatus: dbUser?.codexConnectionStatus ?? "disconnected", codexConnectedAt: dbUser?.codexConnectedAt ?? null, hasAdminKey: !!settings?.adminAiKeyEncrypted, + // v1.4.3: surface whether the operator has a `CODEX_OAUTH_CLIENT_ID` + // configured. Without it the Connect-with-ChatGPT flow is dead, so + // the UI hides the button instead of redirecting to a chatgpt.com + // login the user can never complete. + codexOauthConfigured: isCodexOAuthConfigured(), privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated", lastInsightAt: dbUser?.insightsCachedAt ?? null, }); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index a293a8c74..d62ae99b8 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -22,6 +22,7 @@ export const GET = apiHandler(async () => { aiBaseUrl: true, aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, }, }); @@ -34,6 +35,10 @@ export const GET = apiHandler(async () => { ? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}` : null, hasLocalKey: Boolean(u?.aiLocalKeyEncrypted), + hasOpenaiKey: Boolean(u?.aiOpenaiKeyEncrypted), + openaiKeyPreview: u?.aiOpenaiKeyEncrypted + ? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}` + : null, }); }); @@ -100,6 +105,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => { } } + if (body.openaiKey !== undefined) { + if (body.openaiKey === null || body.openaiKey === "") { + updates.aiOpenaiKeyEncrypted = null; + } else if (typeof body.openaiKey === "string") { + updates.aiOpenaiKeyEncrypted = encrypt(body.openaiKey.trim()); + } + } + if (Object.keys(updates).length === 0) { throw new HttpError(422, "No valid fields"); } diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index fcddd0bbe..b13d89e82 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -18,6 +18,11 @@ interface InsightsSettings { codexStatus: string; codexConnectedAt: string | null; hasAdminKey: boolean; + /** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this + * instance. The UI hides the "Connect with ChatGPT" button when + * false to avoid the v1.4.2 dead-end where the click bounced the + * user to chatgpt.com without any OAuth flow. */ + codexOauthConfigured?: boolean; privacyMode: string; lastInsightAt: string | null; } @@ -29,8 +34,30 @@ interface UserAIProvider { hasAnthropicKey: boolean; anthropicKeyPreview: string | null; hasLocalKey: boolean; + hasOpenaiKey: boolean; + openaiKeyPreview: string | null; } +/** + * Model presets per provider — keeps users out of the freetext box for + * the common case and signals which models the resolver will actually + * use as defaults if the field is left blank. The `__custom__` sentinel + * surfaces a freetext Input so power users can still target any model + * the underlying API speaks (e.g. preview models, fine-tunes). + */ +const MODEL_PRESETS: Record> = { + OPENAI: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5", "o3-mini"], + ANTHROPIC: [ + "claude-sonnet-4-6", + "claude-opus-4-7", + "claude-haiku-4-5", + "claude-3-5-sonnet-latest", + ], + LOCAL: ["llama3.1:8b", "llama3.1:70b", "mistral", "qwen2.5"], +}; + +const CUSTOM_MODEL_SENTINEL = "__custom__"; + export function AiSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -275,14 +302,21 @@ function InsightsSettingsCard({ Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.

- + {settings?.codexOauthConfigured ? ( + + ) : ( +

+ ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — + nutze stattdessen einen eigenen API-Key unten. +

+ )} {settings?.hasAdminKey && (

Alternativ nutzt HealthLog den vom Administrator @@ -353,10 +387,12 @@ function InsightsSettingsCard({ function UserAIProviderSubsection() { const queryClient = useQueryClient(); const [provider, setProvider] = useState(""); - const [model, setModel] = useState(""); + const [modelChoice, setModelChoice] = useState(""); + const [customModel, setCustomModel] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [anthropicKey, setAnthropicKey] = useState(""); const [localKey, setLocalKey] = useState(""); + const [openaiKey, setOpenaiKey] = useState(""); const [saveMsg, setSaveMsg] = useState(null); const [saveOk, setSaveOk] = useState(false); const [testMsg, setTestMsg] = useState(null); @@ -374,26 +410,57 @@ function UserAIProviderSubsection() { }); // React-recommended sync-from-server pattern (no setState-in-effect). + // The model dropdown reflects the saved value: an exact match against + // the preset list selects that preset; anything else (or the empty + // "use default" string) collapses to the custom-input mode so the + // user keeps full control. const dataKey = data ? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}` : null; const [seededKey, setSeededKey] = useState(null); if (dataKey && dataKey !== seededKey) { setSeededKey(dataKey); - setProvider(data!.provider ?? ""); - setModel(data!.model ?? ""); + const nextProvider = data!.provider ?? ""; + setProvider(nextProvider); + const presets = MODEL_PRESETS[nextProvider] ?? []; + const savedModel = data!.model ?? ""; + if (savedModel && presets.includes(savedModel)) { + setModelChoice(savedModel); + setCustomModel(""); + } else if (savedModel) { + setModelChoice(CUSTOM_MODEL_SENTINEL); + setCustomModel(savedModel); + } else { + setModelChoice(""); + setCustomModel(""); + } setBaseUrl(data!.baseUrl ?? ""); } + const presets = MODEL_PRESETS[provider] ?? []; + const effectiveModel = + modelChoice === CUSTOM_MODEL_SENTINEL + ? customModel.trim() + : modelChoice.trim(); + + // Reset model dropdown when provider changes — the preset list is + // provider-specific. Don't wipe the custom model the user typed in + // case they wanted to keep using it (rare but cheap). + function handleProviderChange(next: string) { + setProvider(next); + setModelChoice(""); + } + const saveMutation = useMutation({ mutationFn: async () => { const body: Record = { provider: provider || null, - model: model || null, + model: effectiveModel || null, baseUrl: baseUrl || null, }; if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim(); if (localKey.trim()) body.localKey = localKey.trim(); + if (openaiKey.trim()) body.openaiKey = openaiKey.trim(); const res = await fetch("/api/user/ai-provider", { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -407,6 +474,7 @@ function UserAIProviderSubsection() { setSaveOk(true); setAnthropicKey(""); setLocalKey(""); + setOpenaiKey(""); queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); queryClient.invalidateQueries({ queryKey: ["insights"] }); }, @@ -422,10 +490,11 @@ function UserAIProviderSubsection() { try { const overrideBody: Record = {}; if (provider) overrideBody.provider = provider; - if (model.trim()) overrideBody.model = model.trim(); + if (effectiveModel) overrideBody.model = effectiveModel; if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim(); if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim(); if (localKey.trim()) overrideBody.localKey = localKey.trim(); + if (openaiKey.trim()) overrideBody.openaiKey = openaiKey.trim(); const hasOverride = Object.keys(overrideBody).length > 0; const res = await fetch("/api/ai/test", { method: "POST", @@ -460,7 +529,8 @@ function UserAIProviderSubsection() {

KI-Provider (persönlich)

Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen - für Standard. + für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button + oben.

@@ -470,34 +540,80 @@ function UserAIProviderSubsection() {
- - setModel(e.target.value)} - placeholder={ - provider === "ANTHROPIC" - ? "claude-3-5-sonnet-latest" - : provider === "LOCAL" - ? "llama3:8b" - : "gpt-4o-mini" - } - className="mt-1" - /> + +
+ {modelChoice === CUSTOM_MODEL_SENTINEL && provider && ( +
+ + setCustomModel(e.target.value)} + placeholder={ + provider === "ANTHROPIC" + ? "claude-3-5-sonnet-latest" + : provider === "LOCAL" + ? "llama3:8b" + : "gpt-4o-mini" + } + className="mt-1" + /> +
+ )} + + {provider === "OPENAI" && ( +
+ + setOpenaiKey(e.target.value)} + placeholder="sk-..." + className="mt-1" + /> +

+ Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das + Abo nutze den Verbinden-Button oben. +

+
+ )} + {provider === "ANTHROPIC" && (