feat: admin-configurable default AI providers/models (v0.30.0)
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.
Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
import { db, userAiKeys, userModelPrefs, eq, and } from "@epicure/db";
|
||||
import { decrypt } from "@/lib/encrypt";
|
||||
import { getSiteSetting } from "@/lib/site-settings";
|
||||
import { getSiteSetting, type SiteSettingKey } from "@/lib/site-settings";
|
||||
import type { AiConfig, AiProvider } from "./factory";
|
||||
|
||||
export type ModelUseCase = "text" | "vision" | "mealPlan";
|
||||
|
||||
const USE_CASE_SITE_DEFAULTS: Record<ModelUseCase, { provider: SiteSettingKey; model: SiteSettingKey }> = {
|
||||
text: { provider: "DEFAULT_TEXT_PROVIDER", model: "DEFAULT_TEXT_MODEL" },
|
||||
vision: { provider: "DEFAULT_VISION_PROVIDER", model: "DEFAULT_VISION_MODEL" },
|
||||
mealPlan: { provider: "DEFAULT_MEAL_PLAN_PROVIDER", model: "DEFAULT_MEAL_PLAN_MODEL" },
|
||||
};
|
||||
|
||||
const PROVIDER_API_KEY_SETTING: Record<AiProvider, SiteSettingKey | null> = {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
ollama: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when a user's saved BYOK key exists but fails to decrypt (e.g. the
|
||||
* encryption secret rotated, or the stored ciphertext is corrupt). Callers
|
||||
@@ -52,10 +65,10 @@ export async function getModelConfigForUseCase(userId: string, useCase: ModelUse
|
||||
return withUserKey(userId, config);
|
||||
}
|
||||
|
||||
return getDefaultProviderWithKey(userId);
|
||||
return getDefaultProviderWithKey(userId, useCase);
|
||||
}
|
||||
|
||||
export async function getDefaultProviderWithKey(userId: string): Promise<AiConfig> {
|
||||
export async function getDefaultProviderWithKey(userId: string, useCase?: ModelUseCase): Promise<AiConfig> {
|
||||
const keys = await db.query.userAiKeys.findMany({
|
||||
where: eq(userAiKeys.userId, userId),
|
||||
columns: { provider: true, encryptedKey: true },
|
||||
@@ -63,7 +76,7 @@ export async function getDefaultProviderWithKey(userId: string): Promise<AiConfi
|
||||
|
||||
const order: AiProvider[] = ["openrouter", "openai", "anthropic", "ollama"];
|
||||
|
||||
// 1. User BYOK key takes highest priority
|
||||
// 1. User's own BYOK key takes highest priority — it's their own credentials/billing.
|
||||
for (const p of order) {
|
||||
const row = keys.find((k) => k.provider === p);
|
||||
if (row) {
|
||||
@@ -75,20 +88,26 @@ export async function getDefaultProviderWithKey(userId: string): Promise<AiConfi
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Admin site-setting overrides (DB → env fallback handled inside getSiteSetting)
|
||||
const providerEnvKeys: Record<AiProvider, string | null> = {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
ollama: null,
|
||||
};
|
||||
// 2. Admin-configured default provider/model for this use case, if set.
|
||||
if (useCase) {
|
||||
const setting = USE_CASE_SITE_DEFAULTS[useCase];
|
||||
const defaultProvider = (await getSiteSetting(setting.provider)) as AiProvider | null;
|
||||
if (defaultProvider) {
|
||||
const defaultModel = await getSiteSetting(setting.model);
|
||||
const apiKeySetting = PROVIDER_API_KEY_SETTING[defaultProvider];
|
||||
const apiKey = apiKeySetting ? await getSiteSetting(apiKeySetting) : null;
|
||||
return { provider: defaultProvider, model: defaultModel ?? undefined, apiKey: apiKey ?? undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Admin site-setting API keys, first provider found (no explicit default set)
|
||||
for (const p of order) {
|
||||
const envKey = providerEnvKeys[p];
|
||||
if (!envKey) continue;
|
||||
const apiKey = await getSiteSetting(envKey as Parameters<typeof getSiteSetting>[0]);
|
||||
const apiKeySetting = PROVIDER_API_KEY_SETTING[p];
|
||||
if (!apiKeySetting) continue;
|
||||
const apiKey = await getSiteSetting(apiKeySetting);
|
||||
if (apiKey) return { provider: p, apiKey };
|
||||
}
|
||||
|
||||
// 3. Let factory use raw env vars
|
||||
// 4. Let factory use raw env vars
|
||||
return {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user