mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(ai): treat a stored provider key that cannot be opened as no key
A key id retired from ENCRYPTION_KEYS, or a corrupt row, made decrypt throw inside the provider builders, and a briefing generation answered 500. An unreadable key now drops its entry from the chain like a missing one, with a warning on the event, so the generation answers the no-provider 422 (ai.provider.none). The connection test reads it as no stored key. The parity walk holds that case to the contract again.
This commit is contained in:
1 parent
976b2ba0bc
commit
b02caed221
3 files changed
+64
-32
No files matched your search
+54
-18
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/lib/sharing/provider-work-authority";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
acquireProviderTokenRefreshLock,
|
||||
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
|
||||
@@ -370,6 +371,22 @@ function isOperatorOwned(row: { role: string } | null | undefined): boolean {
|
||||
return row?.role === "ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored provider key, or null when it cannot be opened: a key id retired
|
||||
* from `ENCRYPTION_KEYS`, a corrupt row. An unreadable key is an unconfigured
|
||||
* provider, the same as no key at all. Its entry drops out of the chain, so a
|
||||
* generation answers the no-provider refusal (422 `ai.provider.none`)
|
||||
* instead of the decrypt's 500, and the operator sees the warning.
|
||||
*/
|
||||
function openStoredKey(encrypted: string): string | null {
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("ai: a stored provider key could not be opened");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's
|
||||
* three dedicated columns. The signature names every column this provider is
|
||||
@@ -400,8 +417,12 @@ function buildCompatProvider(row: {
|
||||
}): AIProvider | null {
|
||||
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
|
||||
if (!row.aiCompatBaseUrl || !model) return null;
|
||||
const apiKey = row.aiCompatKeyEncrypted
|
||||
? openStoredKey(row.aiCompatKeyEncrypted)
|
||||
: "";
|
||||
if (apiKey === null) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "",
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl: row.aiCompatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
@@ -424,17 +445,21 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
// 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.
|
||||
const apiKey = openStoredKey(row.aiAnthropicKeyEncrypted);
|
||||
if (apiKey === null) return null;
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(row.aiAnthropicKeyEncrypted),
|
||||
apiKey,
|
||||
model: row.aiModel ?? "claude-sonnet-4-6",
|
||||
});
|
||||
}
|
||||
case "LOCAL": {
|
||||
if (!row.aiBaseUrl) return null;
|
||||
const apiKey = row.aiLocalKeyEncrypted
|
||||
? openStoredKey(row.aiLocalKeyEncrypted)
|
||||
: null;
|
||||
if (row.aiLocalKeyEncrypted && apiKey === null) return null;
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: row.aiLocalKeyEncrypted
|
||||
? decrypt(row.aiLocalKeyEncrypted)
|
||||
: null,
|
||||
apiKey,
|
||||
model: row.aiModel ?? "local-model",
|
||||
baseUrl: row.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
@@ -455,8 +480,10 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
// shared with LOCAL, so a stale LAN URL there would otherwise
|
||||
// redirect the user's OpenAI key to a private host.
|
||||
if (!row.aiOpenaiKeyEncrypted) return null;
|
||||
const apiKey = openStoredKey(row.aiOpenaiKeyEncrypted);
|
||||
if (apiKey === null) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
|
||||
apiKey,
|
||||
model: row.aiModel ?? "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
@@ -515,17 +542,20 @@ async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
if (settings?.adminAiKeyEncrypted) {
|
||||
const adminKey = settings?.adminAiKeyEncrypted
|
||||
? openStoredKey(settings.adminAiKeyEncrypted)
|
||||
: null;
|
||||
if (settings && adminKey !== null) {
|
||||
const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1";
|
||||
if (isAnthropicBaseUrl(baseUrl)) {
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(settings.adminAiKeyEncrypted),
|
||||
apiKey: adminKey,
|
||||
model: settings.adminAiModel ?? "claude-sonnet-4-6",
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(settings.adminAiKeyEncrypted),
|
||||
apiKey: adminKey,
|
||||
model: settings.adminAiModel ?? "gpt-4o",
|
||||
baseUrl,
|
||||
// The instance-wide provider is the operator's own configuration.
|
||||
@@ -1352,8 +1382,10 @@ async function resolveProviderForType(
|
||||
case "openai": {
|
||||
const enc = ctx.userRow?.aiOpenaiKeyEncrypted;
|
||||
if (!enc) return null;
|
||||
const apiKey = openStoredKey(enc);
|
||||
if (apiKey === null) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(enc),
|
||||
apiKey,
|
||||
model: ctx.userRow?.aiModel ?? "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
@@ -1361,17 +1393,21 @@ async function resolveProviderForType(
|
||||
case "anthropic": {
|
||||
const enc = ctx.userRow?.aiAnthropicKeyEncrypted;
|
||||
if (!enc) return null;
|
||||
const apiKey = openStoredKey(enc);
|
||||
if (apiKey === null) return null;
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(enc),
|
||||
apiKey,
|
||||
model: ctx.userRow?.aiModel ?? "claude-sonnet-4-6",
|
||||
});
|
||||
}
|
||||
case "local": {
|
||||
if (!ctx.userRow?.aiBaseUrl) return null;
|
||||
const apiKey = ctx.userRow.aiLocalKeyEncrypted
|
||||
? openStoredKey(ctx.userRow.aiLocalKeyEncrypted)
|
||||
: null;
|
||||
if (ctx.userRow.aiLocalKeyEncrypted && apiKey === null) return null;
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: ctx.userRow.aiLocalKeyEncrypted
|
||||
? decrypt(ctx.userRow.aiLocalKeyEncrypted)
|
||||
: null,
|
||||
apiKey,
|
||||
model: ctx.userRow.aiModel ?? "local-model",
|
||||
baseUrl: ctx.userRow.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(ctx.userRow),
|
||||
@@ -1505,7 +1541,7 @@ async function resolveProviderForTestUnbound(
|
||||
const apiKey =
|
||||
override.anthropicKey?.trim() ||
|
||||
(stored?.aiAnthropicKeyEncrypted
|
||||
? decrypt(stored.aiAnthropicKeyEncrypted)
|
||||
? (openStoredKey(stored.aiAnthropicKeyEncrypted) ?? "")
|
||||
: "");
|
||||
if (!apiKey) {
|
||||
throw new AITestConfigError(422, "Anthropic API key not configured");
|
||||
@@ -1534,7 +1570,7 @@ async function resolveProviderForTestUnbound(
|
||||
const apiKey =
|
||||
override.localKey?.trim() ||
|
||||
(stored?.aiLocalKeyEncrypted
|
||||
? decrypt(stored.aiLocalKeyEncrypted)
|
||||
? (openStoredKey(stored.aiLocalKeyEncrypted) ?? null)
|
||||
: null);
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey,
|
||||
@@ -1583,7 +1619,7 @@ async function resolveProviderForTestUnbound(
|
||||
const apiKey =
|
||||
override.compatKey?.trim() ||
|
||||
(stored?.aiCompatKeyEncrypted
|
||||
? decrypt(stored.aiCompatKeyEncrypted)
|
||||
? (openStoredKey(stored.aiCompatKeyEncrypted) ?? "")
|
||||
: "");
|
||||
return new OpenAIClient({
|
||||
apiKey,
|
||||
@@ -1608,7 +1644,7 @@ async function resolveProviderForTestUnbound(
|
||||
const userKey =
|
||||
override.openaiKey?.trim() ||
|
||||
(stored?.aiOpenaiKeyEncrypted
|
||||
? decrypt(stored.aiOpenaiKeyEncrypted)
|
||||
? (openStoredKey(stored.aiOpenaiKeyEncrypted) ?? "")
|
||||
: "");
|
||||
if (userKey) {
|
||||
return new OpenAIClient({
|
||||
|
||||
+9
-9
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"status": 500,
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Interner Serverfehler",
|
||||
"error": "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
|
||||
"meta": {
|
||||
"errorCode": "http.internal",
|
||||
"errorCode": "ai.provider.none",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 500,
|
||||
"status": 422,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": {
|
||||
"type": "TypeError",
|
||||
"message": "Invalid authentication tag length: 0"
|
||||
}
|
||||
"warnings": [
|
||||
"ai: a stored provider key could not be opened",
|
||||
"ai: a stored provider key could not be opened"
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
|
||||
@@ -797,11 +797,7 @@ describe("POST /api/insights/generate parity", () => {
|
||||
await post(
|
||||
"P09-unreadable-key",
|
||||
{ force: true },
|
||||
{
|
||||
auth: unreadableKey,
|
||||
userId: UNREADABLE_KEY_ID,
|
||||
undeclared: "an unreadable stored key ends in 500 (pinned as it is)",
|
||||
},
|
||||
{ auth: unreadableKey, userId: UNREADABLE_KEY_ID },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user