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.
-
-
- Mit ChatGPT verbinden
-
+ {settings?.codexOauthConfigured ? (
+
+
+ Mit ChatGPT verbinden
+
+ ) : (
+
+ 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() {
setProvider(e.target.value)}
+ onChange={(e) => handleProviderChange(e.target.value)}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
>
— Standard (Admin/Codex) —
- OpenAI
+ OpenAI (API-Key)
Anthropic (Claude)
Lokal (OpenAI-kompatibel)
- ChatGPT OAuth
- Modell
- setModel(e.target.value)}
- placeholder={
- provider === "ANTHROPIC"
- ? "claude-3-5-sonnet-latest"
- : provider === "LOCAL"
- ? "llama3:8b"
- : "gpt-4o-mini"
- }
- className="mt-1"
- />
+ Modell
+ setModelChoice(e.target.value)}
+ disabled={!provider}
+ className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
+ >
+ — Standard —
+ {presets.map((m) => (
+
+ {m}
+
+ ))}
+ {provider && (
+ Eigenes…
+ )}
+
+ {modelChoice === CUSTOM_MODEL_SENTINEL && provider && (
+
+ Eigener Modellname
+ setCustomModel(e.target.value)}
+ placeholder={
+ provider === "ANTHROPIC"
+ ? "claude-3-5-sonnet-latest"
+ : provider === "LOCAL"
+ ? "llama3:8b"
+ : "gpt-4o-mini"
+ }
+ className="mt-1"
+ />
+
+ )}
+
+ {provider === "OPENAI" && (
+
+
+ OpenAI API Key
+ {data?.hasOpenaiKey && (
+
+ (gespeichert {data.openaiKeyPreview})
+
+ )}
+
+
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" && (
diff --git a/src/lib/ai/__tests__/codex-oauth.test.ts b/src/lib/ai/__tests__/codex-oauth.test.ts
index 008758cfe..86dd94431 100644
--- a/src/lib/ai/__tests__/codex-oauth.test.ts
+++ b/src/lib/ai/__tests__/codex-oauth.test.ts
@@ -1,5 +1,10 @@
-import { describe, it, expect } from "vitest";
-import { generatePKCE, generateState, buildAuthorizationUrl } from "../codex-oauth";
+import { afterEach, beforeEach, describe, it, expect } from "vitest";
+import {
+ generatePKCE,
+ generateState,
+ buildAuthorizationUrl,
+ CodexOAuthNotConfiguredError,
+} from "../codex-oauth";
describe("codex-oauth", () => {
describe("generatePKCE", () => {
@@ -29,6 +34,26 @@ describe("codex-oauth", () => {
});
describe("buildAuthorizationUrl", () => {
+ const ORIGINAL_CLIENT_ID = process.env.CODEX_OAUTH_CLIENT_ID;
+
+ beforeEach(() => {
+ // v1.4.3: the URL builder requires CODEX_OAUTH_CLIENT_ID. The
+ // v1.4.2 build silently dropped this so the resulting URL just
+ // hit chatgpt.com login with no OAuth grant flow at all — the
+ // "Connect with ChatGPT" button effectively did nothing in
+ // production. Setting an explicit value here keeps the test
+ // hermetic regardless of the runner env.
+ process.env.CODEX_OAUTH_CLIENT_ID = "test_client_id";
+ });
+
+ afterEach(() => {
+ if (ORIGINAL_CLIENT_ID === undefined) {
+ delete process.env.CODEX_OAUTH_CLIENT_ID;
+ } else {
+ process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL_CLIENT_ID;
+ }
+ });
+
it("builds correct URL with all params", () => {
const url = buildAuthorizationUrl({
codeChallenge: "test-challenge",
@@ -40,10 +65,22 @@ describe("codex-oauth", () => {
expect(parsed.origin).toBe("https://chatgpt.com");
expect(parsed.pathname).toBe("/authorize");
expect(parsed.searchParams.get("response_type")).toBe("code");
+ expect(parsed.searchParams.get("client_id")).toBe("test_client_id");
expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge");
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
expect(parsed.searchParams.get("state")).toBe("test-state");
expect(parsed.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
});
+
+ it("throws CodexOAuthNotConfiguredError when CODEX_OAUTH_CLIENT_ID is unset", () => {
+ delete process.env.CODEX_OAUTH_CLIENT_ID;
+ expect(() =>
+ buildAuthorizationUrl({
+ codeChallenge: "x",
+ state: "y",
+ redirectUri: "https://example.com/cb",
+ }),
+ ).toThrow(CodexOAuthNotConfiguredError);
+ });
});
});
diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts
index bb3c8befe..474762ca9 100644
--- a/src/lib/ai/codex-oauth.ts
+++ b/src/lib/ai/codex-oauth.ts
@@ -15,6 +15,33 @@ export function generateState(): string {
return base64url(randomBytes(32));
}
+/**
+ * Throws when the operator has not configured `CODEX_OAUTH_CLIENT_ID` —
+ * the v1.4.2 build silently dropped the param and rendered a generic
+ * chatgpt.com login that never returned a code, so the "Connect with
+ * ChatGPT" button effectively did nothing. Failing here lets the route
+ * surface a 503 with an actionable message instead of redirecting to
+ * a dead-end page.
+ */
+export class CodexOAuthNotConfiguredError extends Error {
+ constructor() {
+ super(
+ "Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
+ );
+ this.name = "CodexOAuthNotConfiguredError";
+ }
+}
+
+export function getCodexClientId(): string {
+ const id = process.env.CODEX_OAUTH_CLIENT_ID?.trim();
+ if (!id) throw new CodexOAuthNotConfiguredError();
+ return id;
+}
+
+export function isCodexOAuthConfigured(): boolean {
+ return Boolean(process.env.CODEX_OAUTH_CLIENT_ID?.trim());
+}
+
export function buildAuthorizationUrl(params: {
codeChallenge: string;
state: string;
@@ -22,6 +49,7 @@ export function buildAuthorizationUrl(params: {
}): string {
const url = new URL("https://chatgpt.com/authorize");
url.searchParams.set("response_type", "code");
+ url.searchParams.set("client_id", getCodexClientId());
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
@@ -46,6 +74,7 @@ export async function exchangeCodeForTokens(params: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
+ client_id: getCodexClientId(),
code: params.code,
code_verifier: params.codeVerifier,
redirect_uri: params.redirectUri,
@@ -65,6 +94,7 @@ export async function refreshAccessToken(refreshToken: string): Promise {
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
+ aiOpenaiKeyEncrypted: true,
},
});
@@ -241,6 +246,7 @@ export type AITestOverride = {
baseUrl?: string | null;
anthropicKey?: string | null;
localKey?: string | null;
+ openaiKey?: string | null;
};
export class AITestConfigError extends Error {
@@ -270,6 +276,7 @@ export async function resolveProviderForTest(
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
+ aiOpenaiKeyEncrypted: true,
},
});
@@ -333,11 +340,27 @@ export async function resolveProviderForTest(
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.
+ const userKey =
+ override.openaiKey?.trim() ||
+ (stored?.aiOpenaiKeyEncrypted
+ ? decrypt(stored.aiOpenaiKeyEncrypted)
+ : "");
+ if (userKey) {
+ return new OpenAIClient({
+ apiKey: userKey,
+ model: model || "gpt-4o-mini",
+ baseUrl: baseUrl || "https://api.openai.com/v1",
+ });
+ }
const admin = await resolveAdminProvider();
if (admin.type === "none") {
throw new AITestConfigError(
422,
- "Admin OpenAI key is not configured for this instance",
+ "OpenAI key not configured (neither user nor admin)",
);
}
return admin;