feat(ai): add the storage for an OpenAI-compatible gateway provider

Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.

The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 22:27:48 +02:00
1 parent d10ae9e71c
commit d11019aebc
5 files changed
+40 -1

No files matched your search

@@ -0,0 +1,17 @@
-- The OpenAI-compatible gateway provider (#470): a user-configured endpoint
-- that speaks `/v1/chat/completions` — LiteLLM, OpenRouter, vLLM, or anything
-- else on that wire — with its own base URL, its own optional bearer, and its
-- own model.
--
-- Three dedicated columns instead of reusing `ai_base_url` / the OpenAI key.
-- The OpenAI arm pins `api.openai.com` on purpose, so it must have no column
-- in common with a provider whose host the user chooses; and the gateway must
-- never receive the key the user issued for OpenAI. Separating the storage
-- makes both properties structural rather than a comment someone can delete.
--
-- All three are nullable and additive: an install that never configures the
-- provider carries three NULLs and resolves exactly as before.
ALTER TABLE "users" ADD COLUMN "ai_compat_base_url" TEXT;
ALTER TABLE "users" ADD COLUMN "ai_compat_key_encrypted" TEXT;
ALTER TABLE "users" ADD COLUMN "ai_compat_model" TEXT;
+12
View File
@@ -311,6 +311,18 @@ model User {
// before storage.
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
// v1.33.1 (#470) — the OpenAI-compatible gateway provider (LiteLLM,
// OpenRouter, vLLM, any endpoint speaking `/v1/chat/completions`).
// Three DEDICATED columns rather than a reuse of `aiBaseUrl` /
// `aiOpenaiKeyEncrypted`: the OpenAI arm pins `api.openai.com` and must
// never be able to read a user-supplied host, and the gateway credential
// must never be the OpenAI key. Separate columns make that structural
// instead of a comment. The base URL passes the same SSRF floor as the
// Local provider (`isPublicUrl` unless the operator allowlisted the host).
aiCompatBaseUrl String? @map("ai_compat_base_url")
aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted")
aiCompatModel String? @map("ai_compat_model")
// v1.4.16 phase B5b — ordered fallback chain for multi-provider AI
// redundancy. Each entry is `{ providerType, priority, enabled }`,
// sorted by ascending priority at runtime. Credentials live in the
+4 -1
View File
@@ -170,7 +170,8 @@ async function main() {
// "ouraClientSecretEncrypted" "stravaClientIdEncrypted"
// "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted"
// "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted"
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "aiCompatKeyEncrypted"
// "insuranceNumberEncrypted"
const userFields = [
"codexAccessTokenEncrypted",
"codexRefreshTokenEncrypted",
@@ -200,6 +201,8 @@ async function main() {
"aiAnthropicKeyEncrypted",
"aiLocalKeyEncrypted",
"aiOpenaiKeyEncrypted",
// v1.33.1 (#470) — the OpenAI-compatible gateway's optional bearer.
"aiCompatKeyEncrypted",
"insuranceNumberEncrypted",
// v1.23 — TOTP shared secret (second factor).
"totpSecretEncrypted",
+4
View File
@@ -81,6 +81,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" },
{ model: "User", field: "aiLocalKeyEncrypted", kind: "string" },
{ model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" },
// v1.33.1 (#470) — the OpenAI-compatible gateway provider's optional bearer.
// A dedicated column, never the OpenAI key: the gateway host is user-chosen,
// so the credential that reaches it must be one the user issued for it.
{ model: "User", field: "aiCompatKeyEncrypted", kind: "string" },
{ model: "User", field: "insuranceNumberEncrypted", kind: "string" },
// v1.23 — TOTP shared secret (second factor). String ciphertext like the
// other User credential columns.
+3
View File
@@ -262,6 +262,9 @@ export const USER_RESET = {
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: null,
aiCompatKeyEncrypted: null,
aiCompatModel: null,
aiProviderChain: Prisma.DbNull,
aiResponseTimeoutSeconds: null,
disableCoach: false,