feat(admin): configure model providers from database
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { requestAllowedModelProvider } from "@/lib/epay/gateway-policy";
|
||||
import { decryptModelProviderApiKey, modelProviderModelsUrl, modelProviderRequestHeaders, type ModelProviderType } from "@/lib/model-provider-policy";
|
||||
|
||||
export const runtime="nodejs";
|
||||
const schema=z.object({providerId:z.string().uuid()}).strict();
|
||||
type Row={provider_type:ModelProviderType;base_url:string|null;encrypted_api_key:string|null;enabled:boolean};
|
||||
function normalize(payload:unknown){const data=payload&&typeof payload==="object"&&"data" in payload?(payload as {data?:unknown}).data:null;if(!Array.isArray(data))return[];const seen=new Set<string>();const out:{id:string;label:string}[]=[];for(const item of data){if(out.length>=200)break;if(!item||typeof item!=="object")continue;const raw="id" in item?(item as {id?:unknown}).id:undefined;if(typeof raw!=="string")continue;const id=raw.trim();if(!id||id.length>160||seen.has(id))continue;seen.add(id);const display="display_name" in item?(item as {display_name?:unknown}).display_name:undefined;out.push({id,label:typeof display==="string"&&display.trim()?display.trim().slice(0,160):id});}return out;}
|
||||
export async function POST(request:Request){try{await requireHighRiskAdminMutation(request,"models.test");const parsed=schema.safeParse(await request.json().catch(()=>null));if(!parsed.success)return invalidQueryResponse(parsed.error.flatten());const rows=await queryAdminRows<Row>("select provider_type,base_url,encrypted_api_key,enabled from public.model_providers where id=$1",[parsed.data.providerId]);const p=rows[0];if(!p||!p.enabled)return Response.json({error:"模型供应商不可用"},{status:404});let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key});}catch{return Response.json({error:"模型供应商配置不可用"},{status:409});}const upstream=await requestAllowedModelProvider(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key),process.env,{timeoutMs:8000,maxResponseBytes:256*1024});if(upstream.status<200||upstream.status>299)return Response.json({error:"模型发现失败"},{status:502});let payload:unknown;try{payload=JSON.parse(upstream.body.toString("utf8"));}catch{return Response.json({error:"模型发现失败"},{status:502});}return Response.json({data:normalize(payload)});}catch(e){return adminErrorResponse(e);}}
|
||||
@@ -18,19 +18,16 @@ import {
|
||||
modelSettingsContainSecrets,
|
||||
sanitizeModelSettings,
|
||||
} from "@/lib/model-catalog";
|
||||
import { modelProviderSecretValue } from "@/lib/model-provider-policy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const secretRefSchema = z.string().regex(/^env:[A-Z][A-Z0-9_]*$/);
|
||||
const providerSchema = z.object({
|
||||
action: z.literal("saveProvider"),
|
||||
id: z.string().uuid().nullable().optional(),
|
||||
code: z.string().regex(/^[a-z][a-z0-9_-]{1,63}$/),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
providerType: z.enum(["openai", "openai-compatible"]),
|
||||
providerType: z.enum(["openai", "openai-compatible", "anthropic"]),
|
||||
baseUrl: z.string().url().startsWith("https://").nullable().optional(),
|
||||
secretRef: secretRefSchema.optional(),
|
||||
apiKey: z.string().max(4096).optional(),
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
@@ -74,9 +71,9 @@ type ProviderRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
provider_type: "openai" | "openai-compatible";
|
||||
provider_type: "openai" | "openai-compatible" | "anthropic";
|
||||
base_url: string | null;
|
||||
secret_ref: string;
|
||||
encrypted_api_key: string | null;
|
||||
enabled: boolean;
|
||||
updated_at: Date;
|
||||
};
|
||||
@@ -148,7 +145,7 @@ export async function GET(request: Request) {
|
||||
and ($2::text is null or v.status=$2)
|
||||
order by v.created_at desc limit $3 offset $4
|
||||
`, [q, parsed.data.status ?? null, parsed.data.pageSize, pageOffset(parsed.data.page, parsed.data.pageSize)]),
|
||||
queryAdminRows<ProviderRow>("select id,code,name,provider_type,base_url,secret_ref,enabled,updated_at from public.model_providers order by code"),
|
||||
queryAdminRows<ProviderRow>("select id,code,name,provider_type,base_url,(encrypted_api_key is not null) secret_configured,enabled,updated_at from public.model_providers order by code"),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
data: models.map(modelOutput),
|
||||
@@ -159,11 +156,7 @@ export async function GET(request: Request) {
|
||||
name: provider.name,
|
||||
providerType: provider.provider_type,
|
||||
baseUrl: provider.base_url,
|
||||
secretConfigured: Boolean(modelProviderSecretValue({
|
||||
code: provider.code,
|
||||
providerType: provider.provider_type,
|
||||
secretRef: provider.secret_ref,
|
||||
})),
|
||||
secretConfigured: Boolean((provider as ProviderRow & { secret_configured?: boolean }).secret_configured),
|
||||
enabled: provider.enabled,
|
||||
updatedAt: provider.updated_at.toISOString(),
|
||||
})),
|
||||
@@ -195,7 +188,7 @@ export async function POST(request: Request) {
|
||||
{
|
||||
queryRows: (sql, values) => queryAdminRows<Record<string, unknown>>(sql, values),
|
||||
assertAllowedUrl: (value) => assertAllowedModelProviderUrl(value).then(() => undefined),
|
||||
probeAllowed: (value, apiKey) => probeAllowedModelProvider(value, apiKey),
|
||||
probeAllowed: (value, headers) => probeAllowedModelProvider(value, headers),
|
||||
invalidateCatalog: invalidateLanguageModelCatalog,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
requestCookie,
|
||||
requireAdminMfaIfRequired,
|
||||
requireAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
@@ -43,7 +42,6 @@ export async function POST(request: Request) {
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
|
||||
const session = await requireAdminMutation(request, parsed.data.permission);
|
||||
requireAdminMfaIfRequired(request, session);
|
||||
const auth = getIdentityEmailOtpApi();
|
||||
const proofContext = {
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getBirthTimeGuideAgent } from "@/mastra";
|
||||
import { defaultLanguageModel } from "@/mastra/model";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import {
|
||||
recordJourneyTransitionMetric,
|
||||
} from "@/lib/birth-time-journey-telemetry";
|
||||
@@ -71,7 +71,8 @@ export async function POST(request: Request) {
|
||||
store,
|
||||
engine: createJyotishBirthTimeJourneyEngine(),
|
||||
});
|
||||
const model = defaultLanguageModel();
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
|
||||
const generator = model
|
||||
? {
|
||||
async generate(prompt: string) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getJyotishAgent,
|
||||
runConsultationWorkflow,
|
||||
} from "@/mastra";
|
||||
import { languageModelConfigurationMessage } from "@/mastra/model";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import {
|
||||
consultationEntrypointSchema,
|
||||
@@ -148,6 +147,16 @@ export async function POST(request: Request) {
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
|
||||
if (!sessionModel) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "模型暂不可用",
|
||||
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.entrypoint === "birth_time_rectification") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -406,9 +415,7 @@ export async function POST(request: Request) {
|
||||
{
|
||||
error: "暂时无法生成解读",
|
||||
message: "咨询服务暂时不可用,请稍后再试。",
|
||||
recovery: languageModelConfigurationMessage()
|
||||
? "当前没有可用的咨询模型,请联系管理员。"
|
||||
: "稍后重试,或换一个模型继续。",
|
||||
recovery: "稍后重试,或换一个模型继续。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getTruthSourceRuntimeIdentity } from "@/lib/truth-source-runtime-identity";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
|
||||
type Check = {
|
||||
status: "ok" | "degraded" | "blocked";
|
||||
@@ -16,12 +17,6 @@ function envCheck(names: string[]): Check {
|
||||
: { status: "ok" };
|
||||
}
|
||||
|
||||
function anyEnvCheck(names: string[]): Check {
|
||||
return names.some((name) => process.env[name])
|
||||
? { status: "ok" }
|
||||
: { status: "blocked", message: `missing_one_of:${names.join("|")}` };
|
||||
}
|
||||
|
||||
async function jyotishApiCheck(): Promise<Check> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
@@ -47,6 +42,14 @@ async function jyotishApiCheck(): Promise<Check> {
|
||||
}
|
||||
}
|
||||
|
||||
async function modelCatalogCheck(): Promise<Check> {
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
const defaults = catalog.models.filter((model) => model.isDefault && model.id === catalog.defaultModelId);
|
||||
return defaults.length === 1
|
||||
? { status: "ok" }
|
||||
: { status: "blocked", message: catalog.issues[0] ?? "default_model_unavailable" };
|
||||
}
|
||||
|
||||
function aggregate(checks: Record<string, Check>) {
|
||||
if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked";
|
||||
if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded";
|
||||
@@ -72,7 +75,8 @@ export async function GET() {
|
||||
const checks = {
|
||||
web: { status: "ok" } satisfies Check,
|
||||
...databaseChecks,
|
||||
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
|
||||
modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]),
|
||||
modelCatalog: await modelCatalogCheck(),
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
researchTruthSource: {
|
||||
status: truthSourceIdentity.status,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getOnboardingAgent } from "@/mastra";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
@@ -66,7 +66,10 @@ export const POST = createOnboardingPost({
|
||||
},
|
||||
generateText: async (name, signal) => {
|
||||
const preferredModelId = process.env.ONBOARDING_MODEL_ID?.trim() || "deepseek-v4-flash";
|
||||
const model = resolveLanguageModel(preferredModelId) ?? defaultLanguageModel();
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
const model = catalog.models.find((entry) => entry.id === preferredModelId)
|
||||
?? catalog.models.find((entry) => entry.id === catalog.defaultModelId)
|
||||
?? null;
|
||||
if (!model) return null;
|
||||
const result = await getOnboardingAgent(model).generate([
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runConsultationWorkflow } from "@/mastra";
|
||||
import { createPersonalReportAgent } from "@/mastra/personal-report";
|
||||
import { defaultLanguageModel } from "@/mastra/model";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import {
|
||||
resolveSkillSnapshot,
|
||||
} from "@/lib/personal-report-generation";
|
||||
@@ -62,6 +62,8 @@ export async function POST(request: Request) {
|
||||
profileError = result.error;
|
||||
}
|
||||
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
const defaultModel = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
|
||||
const deps: ReportCreateCoreDeps = {
|
||||
requestUrl: request.url,
|
||||
origin: request.headers.get("origin"),
|
||||
@@ -119,7 +121,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
},
|
||||
persistence,
|
||||
model: defaultLanguageModel(),
|
||||
model: defaultModel,
|
||||
runWorkflow: (input) => runConsultationWorkflow(input),
|
||||
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
|
||||
skillSnapshot: resolveSkillSnapshot(),
|
||||
|
||||
Reference in New Issue
Block a user