feat(models): secure catalog and pin model versions
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
|
||||
export const runtime="nodejs";
|
||||
type Row={id:string;model_id:string;from_version:number|null;to_version:number;action:string;actor_email:string|null;reason:string;request_id:string;created_at:Date;total_count:string};
|
||||
export async function GET(request:Request){try{await requirePermission("models.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select e.id,c.model_id,f.version from_version,t.version to_version,e.action,u.email actor_email,e.reason,e.request_id,e.created_at,count(*) over()::text total_count from public.model_publish_events e join public.model_configs c on c.id=e.config_id left join public.model_config_versions f on f.id=e.from_version_id join public.model_config_versions t on t.id=e.to_version_id left join identity.users u on u.id=e.actor_user_id where ($1::text is null or c.model_id ilike $1 or u.email ilike $1 or e.request_id ilike $1) and ($2::text is null or e.action=$2) order by e.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,modelId:r.model_id,fromVersion:r.from_version,toVersion:r.to_version,action:r.action,actorEmail:r.actor_email,reason:r.reason,requestId:r.request_id,createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requestId,
|
||||
requireAdminMutation,
|
||||
requireHighRiskAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
import { assertAllowedModelProviderUrl, probeAllowedModelProvider } from "@/lib/epay/gateway-policy";
|
||||
import { handleAdminModelMutation, type AdminModelMutation } from "@/lib/admin/model-mutation-handler";
|
||||
import {
|
||||
invalidateLanguageModelCatalog,
|
||||
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"]),
|
||||
baseUrl: z.string().url().startsWith("https://").nullable().optional(),
|
||||
secretRef: secretRefSchema.optional(),
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const settingsSchema = z.record(z.string(), z.unknown()).superRefine((value, context) => {
|
||||
if (modelSettingsContainSecrets(value)) {
|
||||
context.addIssue({ code: "custom", message: "settings 不允许包含密钥、令牌或连接凭据" });
|
||||
}
|
||||
});
|
||||
const draftSchema = z.object({
|
||||
action: z.literal("saveDraft"),
|
||||
modelId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/),
|
||||
versionId: z.string().uuid().nullable().optional(),
|
||||
providerId: z.string().uuid(),
|
||||
label: z.string().trim().min(1).max(60),
|
||||
description: z.string().trim().max(200).default(""),
|
||||
providerModel: z.string().trim().min(1).max(160),
|
||||
modelTier: z.enum(["standard", "premium", "internal"]),
|
||||
creditCost: z.number().int().positive(),
|
||||
contextWindow: z.number().int().positive().nullable().optional(),
|
||||
inputCostMicrousdPerMillion: z.number().int().nonnegative(),
|
||||
outputCostMicrousdPerMillion: z.number().int().nonnegative(),
|
||||
enabled: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
fallbackModelId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/).nullable().optional(),
|
||||
settings: settingsSchema.default({}),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const actionSchema = z.discriminatedUnion("action", [
|
||||
providerSchema,
|
||||
draftSchema,
|
||||
z.object({ action: z.literal("test"), versionId: z.string().uuid() }).strict(),
|
||||
z.object({ action: z.literal("publish"), versionId: z.string().uuid(), reason: z.string().trim().min(1).max(500) }).strict(),
|
||||
z.object({ action: z.literal("rollback"), configId: z.string().uuid(), targetVersion: z.number().int().positive(), reason: z.string().trim().min(1).max(500) }).strict(),
|
||||
]).superRefine((value, context) => {
|
||||
if (value.action === "saveDraft" && value.isDefault && !value.enabled) {
|
||||
context.addIssue({ code: "custom", path: ["isDefault"], message: "默认模型必须启用" });
|
||||
}
|
||||
});
|
||||
|
||||
type ProviderRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
provider_type: "openai" | "openai-compatible";
|
||||
base_url: string | null;
|
||||
secret_ref: string;
|
||||
enabled: boolean;
|
||||
updated_at: Date;
|
||||
};
|
||||
type ModelRow = {
|
||||
id: string;
|
||||
config_id: string;
|
||||
model_id: string;
|
||||
version: number;
|
||||
provider_id: string;
|
||||
provider_code: string;
|
||||
label: string;
|
||||
description: string;
|
||||
provider_model: string;
|
||||
model_tier: string;
|
||||
credit_cost: number;
|
||||
context_window: number | null;
|
||||
input_cost: string;
|
||||
output_cost: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
fallback_model_id: string | null;
|
||||
status: string;
|
||||
settings: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
published_at: Date | null;
|
||||
total_count: string;
|
||||
};
|
||||
const modelOutput = (row: ModelRow) => ({
|
||||
id: row.id,
|
||||
configId: row.config_id,
|
||||
modelId: row.model_id,
|
||||
version: row.version,
|
||||
providerId: row.provider_id,
|
||||
providerCode: row.provider_code,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
providerModel: row.provider_model,
|
||||
modelTier: row.model_tier,
|
||||
creditCost: row.credit_cost,
|
||||
contextWindow: row.context_window,
|
||||
inputCostMicrousdPerMillion: Number(row.input_cost),
|
||||
outputCostMicrousdPerMillion: Number(row.output_cost),
|
||||
enabled: row.enabled,
|
||||
isDefault: row.is_default,
|
||||
fallbackModelId: row.fallback_model_id,
|
||||
status: row.status,
|
||||
settings: sanitizeModelSettings(row.settings),
|
||||
createdAt: row.created_at.toISOString(),
|
||||
publishedAt: row.published_at?.toISOString() ?? null,
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("models.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const q = parsed.data.q ? `%${parsed.data.q}%` : null;
|
||||
const [models, providers] = await Promise.all([
|
||||
queryAdminRows<ModelRow>(`
|
||||
select v.id,v.config_id,c.model_id,v.version,v.provider_id,p.code provider_code,v.label,v.description,
|
||||
v.provider_model,v.model_tier,v.credit_cost,v.context_window,
|
||||
v.input_cost_microusd_per_million::text input_cost,
|
||||
v.output_cost_microusd_per_million::text output_cost,v.enabled,v.is_default,
|
||||
v.fallback_model_id,v.status,v.settings,v.created_at,v.published_at,count(*) over()::text total_count
|
||||
from public.model_config_versions v
|
||||
join public.model_configs c on c.id=v.config_id
|
||||
join public.model_providers p on p.id=v.provider_id
|
||||
where ($1::text is null or c.model_id ilike $1 or v.label ilike $1 or p.code ilike $1)
|
||||
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"),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
data: models.map(modelOutput),
|
||||
total: Number(models[0]?.total_count ?? 0),
|
||||
providers: providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
code: provider.code,
|
||||
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,
|
||||
})),
|
||||
enabled: provider.enabled,
|
||||
updatedAt: provider.updated_at.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = actionSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) return invalidQueryResponse(body.error.flatten());
|
||||
const permission = body.data.action === "publish"
|
||||
? "models.publish"
|
||||
: body.data.action === "rollback"
|
||||
? "models.rollback"
|
||||
: body.data.action === "test"
|
||||
? "models.test"
|
||||
: "models.write";
|
||||
const highRisk = body.data.action === "saveProvider" || body.data.action === "publish" || body.data.action === "rollback";
|
||||
const session = highRisk
|
||||
? await requireHighRiskAdminMutation(request, permission)
|
||||
: await requireAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
return await handleAdminModelMutation(
|
||||
body.data as AdminModelMutation,
|
||||
{ actorUserId: session.user.id, requestId: rid },
|
||||
{
|
||||
queryRows: (sql, values) => queryAdminRows<Record<string, unknown>>(sql, values),
|
||||
assertAllowedUrl: (value) => assertAllowedModelProviderUrl(value).then(() => undefined),
|
||||
probeAllowed: (value, apiKey) => probeAllowedModelProvider(value, apiKey),
|
||||
invalidateCatalog: invalidateLanguageModelCatalog,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,20 @@ import {
|
||||
runConsultationWorkflow,
|
||||
toAgentConsultationContext,
|
||||
} from "@/mastra";
|
||||
import {
|
||||
languageModelConfigurationMessage,
|
||||
resolveLanguageModel,
|
||||
} from "@/mastra/model";
|
||||
import { languageModelConfigurationMessage } from "@/mastra/model";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import {
|
||||
consultationEntrypointSchema,
|
||||
resolveConsultationQuestion,
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import { CreditRpcError, runCreditRpc } from "@/lib/consultation-billing";
|
||||
import {
|
||||
authorizeUsage,
|
||||
completeUsage,
|
||||
CreditRpcError,
|
||||
releaseUsage,
|
||||
} from "@/lib/consultation-billing";
|
||||
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { streamTextResponse } from "@/lib/stream-text-response";
|
||||
@@ -45,6 +48,7 @@ export const maxDuration = 60;
|
||||
|
||||
const chatRequestMetadataSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
sessionId: z.string().uuid(),
|
||||
modelId: z.string().trim().min(1).max(64),
|
||||
name: z.string().trim().max(80).optional().default(""),
|
||||
history: z
|
||||
@@ -166,38 +170,6 @@ function rangeBoundaryWorkflowContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function recordModelUsage(
|
||||
accounting: ReturnType<typeof createAdminSupabaseClient>,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
modelId: string,
|
||||
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
|
||||
) {
|
||||
try {
|
||||
const resolved = await usage;
|
||||
const { error } = await accounting
|
||||
.from("credit_transactions")
|
||||
.update({
|
||||
model: modelId,
|
||||
input_tokens: Math.max(0, Math.trunc(resolved.inputTokens ?? 0)),
|
||||
output_tokens: Math.max(0, Math.trunc(resolved.outputTokens ?? 0)),
|
||||
})
|
||||
.eq("user_id", userId)
|
||||
.eq("transaction_type", "reserve")
|
||||
.eq("request_id", requestId);
|
||||
|
||||
if (error)
|
||||
console.warn(
|
||||
`[billing] unable to record model usage request=${requestId} model=${modelId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.warn(
|
||||
`[billing] unable to read model usage request=${requestId} model=${modelId} reason=${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
||||
let accounting: ReturnType<typeof createAdminSupabaseClient>;
|
||||
@@ -232,6 +204,35 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const { data: chatSession, error: chatSessionError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,model_id,model_config_version,session_type")
|
||||
.eq("id", parsed.data.sessionId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
if (chatSessionError) {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!chatSession || chatSession.session_type !== "consultation") {
|
||||
return NextResponse.json(
|
||||
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
|
||||
return NextResponse.json(
|
||||
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const sessionModel = await resolveSessionLanguageModel(
|
||||
chatSession.model_id,
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
|
||||
if (parsed.data.entrypoint === "birth_time_rectification") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -383,20 +384,25 @@ export async function POST(request: Request) {
|
||||
return data;
|
||||
},
|
||||
reserve: () => reserveConsultationModel(
|
||||
parsed.data.modelId,
|
||||
resolveLanguageModel,
|
||||
() => handoffExecution?.billingReused
|
||||
chatSession.model_id,
|
||||
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
|
||||
(model) => handoffExecution?.billingReused
|
||||
? Promise.resolve({
|
||||
success: true,
|
||||
credits: handoffExecution.credits ?? null,
|
||||
error_code: null,
|
||||
})
|
||||
: runCreditRpc(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
: authorizeUsage(accounting, {
|
||||
userId,
|
||||
requestId,
|
||||
),
|
||||
featureKey: "chat.standard",
|
||||
requestedModelId: model.id,
|
||||
creditCost: model.creditCost,
|
||||
}).then((result) => ({
|
||||
success: result.success,
|
||||
credits: result.credits,
|
||||
error_code: result.reason,
|
||||
})),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -499,12 +505,7 @@ export async function POST(request: Request) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runCreditRpc(
|
||||
accounting,
|
||||
"cancel_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
);
|
||||
await releaseUsage(accounting, userId, requestId, "consultation_cancelled");
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
@@ -513,17 +514,28 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
const usageStartedAt = Date.now();
|
||||
async function complete(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
|
||||
if (handoffExecution?.status === "ready") {
|
||||
await settleHandoff(true);
|
||||
return;
|
||||
}
|
||||
const result = await runCreditRpc(
|
||||
accounting,
|
||||
"complete_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
);
|
||||
const resolved = await usage;
|
||||
const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0));
|
||||
const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0));
|
||||
const costMicrousd = Math.round((
|
||||
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
|
||||
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
|
||||
) / 1_000_000);
|
||||
const result = await completeUsage(accounting, userId, requestId, {
|
||||
eventKey: requestId,
|
||||
actualModelId: selectedModel.id,
|
||||
modelConfigVersion: selectedModel.configVersion,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costMicrousd,
|
||||
durationMs: Date.now() - usageStartedAt,
|
||||
});
|
||||
if (!result.success)
|
||||
throw new CreditRpcError(result.error_code || "completion_rejected");
|
||||
}
|
||||
@@ -552,18 +564,9 @@ export async function POST(request: Request) {
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const completeAndRecordUsage = async () => {
|
||||
await complete();
|
||||
void recordModelUsage(
|
||||
accounting,
|
||||
userId,
|
||||
requestId,
|
||||
modelSelection.usageModelId,
|
||||
result.totalUsage,
|
||||
);
|
||||
};
|
||||
const completeWithUsage = () => complete(result.totalUsage);
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
settle(emitted ? completeWithUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
|
||||
mode: "mastra",
|
||||
@@ -576,8 +579,8 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": "birth-minute",
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
...(handoff ? { onFirstOutput: () => settle(completeWithUsage) } : {}),
|
||||
onComplete: () => settle(completeWithUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
});
|
||||
@@ -626,18 +629,9 @@ export async function POST(request: Request) {
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const completeAndRecordUsage = async () => {
|
||||
await complete();
|
||||
void recordModelUsage(
|
||||
accounting,
|
||||
userId,
|
||||
requestId,
|
||||
modelSelection.usageModelId,
|
||||
result.totalUsage,
|
||||
);
|
||||
};
|
||||
const completeWithUsage = () => complete(result.totalUsage);
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
settle(emitted ? completeWithUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard("unverified_birth_time", false),
|
||||
mode: "mastra",
|
||||
@@ -650,8 +644,8 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": "unverified_birth_time",
|
||||
},
|
||||
onFirstOutput: () => settle(completeAndRecordUsage),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onFirstOutput: () => settle(completeWithUsage),
|
||||
onComplete: () => settle(completeWithUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
});
|
||||
@@ -685,18 +679,9 @@ export async function POST(request: Request) {
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const completeAndRecordUsage = async () => {
|
||||
await complete();
|
||||
void recordModelUsage(
|
||||
accounting,
|
||||
userId,
|
||||
requestId,
|
||||
modelSelection.usageModelId,
|
||||
result.totalUsage,
|
||||
);
|
||||
};
|
||||
const completeWithUsage = () => complete(result.totalUsage);
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
settle(emitted ? completeWithUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard(
|
||||
consultationMode,
|
||||
@@ -712,8 +697,8 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
...(handoff ? { onFirstOutput: () => settle(completeAndRecordUsage) } : {}),
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
...(handoff ? { onFirstOutput: () => settle(completeWithUsage) } : {}),
|
||||
onComplete: () => settle(completeWithUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
});
|
||||
@@ -721,7 +706,7 @@ export async function POST(request: Request) {
|
||||
await cancel();
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(
|
||||
`[consult] generation failed request=${requestId} model=${modelSelection.usageModelId} reason=${reason}`,
|
||||
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { publicLanguageModelCatalog } from "@/mastra/model";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -26,7 +26,11 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = publicLanguageModelCatalog();
|
||||
const resolved = await loadLanguageModelCatalog();
|
||||
const catalog = {
|
||||
models: resolved.publicModels,
|
||||
defaultModelId: resolved.defaultModelId,
|
||||
};
|
||||
if (!catalog.defaultModelId || catalog.models.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "模型服务尚未配置", message: "当前没有可用的咨询模型。" },
|
||||
|
||||
@@ -3,9 +3,9 @@ import { z } from "zod";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessage } from "@/lib/chat-message-view";
|
||||
import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import { runCreditRpc } from "@/lib/consultation-billing";
|
||||
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
@@ -80,29 +80,26 @@ function currentTimeContext(now = new Date()) {
|
||||
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
|
||||
}
|
||||
|
||||
async function recordModelUsage(
|
||||
async function rectificationBillingRequestId(
|
||||
accounting: ReturnType<typeof createAdminSupabaseClient>,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
modelId: string,
|
||||
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
|
||||
sessionId: string,
|
||||
) {
|
||||
try {
|
||||
const resolved = await usage;
|
||||
const { error } = await accounting
|
||||
.from("credit_transactions")
|
||||
.update({
|
||||
model: modelId,
|
||||
input_tokens: Math.max(0, Math.trunc(resolved.inputTokens ?? 0)),
|
||||
output_tokens: Math.max(0, Math.trunc(resolved.outputTokens ?? 0)),
|
||||
})
|
||||
.eq("user_id", userId)
|
||||
.eq("transaction_type", "reserve")
|
||||
.eq("request_id", requestId);
|
||||
if (error) console.warn(`[agentic-rectification] unable to record usage request=${requestId}`);
|
||||
} catch (error) {
|
||||
console.warn(`[agentic-rectification] usage read failed request=${requestId}`, error instanceof Error ? error.name : "UnknownError");
|
||||
}
|
||||
const billingRequestPrefix = `rectification:${sessionId}`;
|
||||
const { data, error } = await accounting
|
||||
.from("usage_reservations")
|
||||
.select("request_id,status")
|
||||
.eq("user_id", userId)
|
||||
.eq("feature_key", "rectification")
|
||||
.like("request_id", `${billingRequestPrefix}%`);
|
||||
if (error) throw new Error("RectificationBillingLookupError");
|
||||
const reservations = (data ?? []) as Array<{ request_id: string; status: string }>;
|
||||
const active = reservations.find((item) => item.status === "completed")
|
||||
?? reservations.find((item) => item.status === "reserved");
|
||||
if (active) return active.request_id;
|
||||
return reservations.length === 0
|
||||
? billingRequestPrefix
|
||||
: `${billingRequestPrefix}:retry:${reservations.length}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -183,7 +180,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const { data: chatSession, error: chatSessionError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,messages,session_type")
|
||||
.select("id,messages,session_type,model_id,model_config_version")
|
||||
.eq("id", parsed.data.sessionId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
@@ -251,8 +248,16 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const selectedModel = (conversation.modelId ? resolveLanguageModel(conversation.modelId) : null)
|
||||
?? defaultLanguageModel();
|
||||
if (!chatSession.model_id || (conversation.modelId && conversation.modelId !== chatSession.model_id)) {
|
||||
return NextResponse.json(
|
||||
{ error: "会话模型已经变化", message: "请刷新生时校正会话后重试,本次不会扣除点数。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const selectedModel = await resolveSessionLanguageModel(
|
||||
chatSession.model_id,
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
if (!selectedModel) {
|
||||
return NextResponse.json(
|
||||
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
|
||||
@@ -261,13 +266,20 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
let reserveResult;
|
||||
let billingRequestId: string;
|
||||
try {
|
||||
reserveResult = await runCreditRpc(
|
||||
billingRequestId = await rectificationBillingRequestId(
|
||||
accounting,
|
||||
"begin_consultation_credit",
|
||||
userId,
|
||||
requestId,
|
||||
conversation.sessionId,
|
||||
);
|
||||
reserveResult = await authorizeUsage(accounting, {
|
||||
userId,
|
||||
requestId: billingRequestId,
|
||||
featureKey: "rectification",
|
||||
requestedModelId: selectedModel.id,
|
||||
creditCost: selectedModel.creditCost,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.error(`[agentic-rectification] credit reserve failed request=${requestId} reason=${reason}`);
|
||||
@@ -277,11 +289,11 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
if (!reserveResult.success) {
|
||||
const insufficient = reserveResult.error_code === "insufficient_credits";
|
||||
const insufficient = reserveResult.reason === "insufficient_credits";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
|
||||
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.error_code || "请稍后重试。",
|
||||
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.reason || "请稍后重试。",
|
||||
},
|
||||
{ status: insufficient ? 402 : 503 },
|
||||
);
|
||||
@@ -296,18 +308,37 @@ export async function POST(request: Request) {
|
||||
let emitted = false;
|
||||
let raw = "";
|
||||
let settled = false;
|
||||
const settle = async (complete: boolean) => {
|
||||
if (settled) return;
|
||||
const usageStartedAt = Date.now();
|
||||
const settle = async (complete: boolean, usage?: Promise<{ inputTokens?: number; outputTokens?: number }>) => {
|
||||
if (settled) return true;
|
||||
settled = true;
|
||||
try {
|
||||
let settlement;
|
||||
if (complete) {
|
||||
await runCreditRpc(accounting, "complete_consultation_credit", userId, requestId);
|
||||
const resolved = await usage;
|
||||
const inputTokens = Math.max(0, Math.trunc(resolved?.inputTokens ?? 0));
|
||||
const outputTokens = Math.max(0, Math.trunc(resolved?.outputTokens ?? 0));
|
||||
settlement = await completeUsage(accounting, userId, billingRequestId, {
|
||||
eventKey: requestId,
|
||||
actualModelId: selectedModel.id,
|
||||
modelConfigVersion: selectedModel.configVersion,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costMicrousd: Math.round((
|
||||
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
|
||||
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
|
||||
) / 1_000_000),
|
||||
durationMs: Date.now() - usageStartedAt,
|
||||
});
|
||||
} else {
|
||||
await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId);
|
||||
settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled");
|
||||
}
|
||||
if (!settlement.success) throw new Error(settlement.error_code ?? "usage_settlement_failed");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
console.warn(`[agentic-rectification] credit settle failed request=${requestId} complete=${complete} reason=${reason}`);
|
||||
const reason = error instanceof Error ? error.message : "UnknownError";
|
||||
console.warn(`[agentic-rectification] usage settle failed request=${requestId} complete=${complete} reason=${reason}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const send = (event: Record<string, unknown>) => {
|
||||
@@ -335,13 +366,6 @@ export async function POST(request: Request) {
|
||||
raw += chunk;
|
||||
send({ type: "delta", text: chunk });
|
||||
}
|
||||
void recordModelUsage(
|
||||
accounting,
|
||||
userId,
|
||||
requestId,
|
||||
selectedModel.id,
|
||||
result.totalUsage,
|
||||
);
|
||||
const reply = parseAgentReply(raw, "general");
|
||||
if (!emitted || !reply.text) {
|
||||
console.warn(`[agentic-rectification] empty response request=${requestId}`);
|
||||
@@ -379,8 +403,12 @@ export async function POST(request: Request) {
|
||||
} catch {
|
||||
console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`);
|
||||
}
|
||||
if (!await settle(true, result.totalUsage)) {
|
||||
send({ type: "error", message: "生时校正回复已生成,但用量结算失败,请稍后重试。" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
send({ type: "done", emitted: true });
|
||||
await settle(true);
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.name : "UnknownError";
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
expectedModelProviderSecretRef,
|
||||
modelConnectionTestSucceeded,
|
||||
modelProviderModelsUrl,
|
||||
modelProviderSecretValue,
|
||||
type ModelProviderType,
|
||||
} from "../model-provider-policy.ts";
|
||||
|
||||
type SaveProviderAction = {
|
||||
action: "saveProvider";
|
||||
id?: string | null;
|
||||
code: string;
|
||||
name: string;
|
||||
providerType: ModelProviderType;
|
||||
baseUrl?: string | null;
|
||||
secretRef?: string;
|
||||
enabled: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type SaveDraftAction = {
|
||||
action: "saveDraft";
|
||||
modelId: string;
|
||||
versionId?: string | null;
|
||||
providerId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
providerModel: string;
|
||||
modelTier: "standard" | "premium" | "internal";
|
||||
creditCost: number;
|
||||
contextWindow?: number | null;
|
||||
inputCostMicrousdPerMillion: number;
|
||||
outputCostMicrousdPerMillion: number;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
fallbackModelId?: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type AdminModelMutation = SaveProviderAction | SaveDraftAction | {
|
||||
action: "test";
|
||||
versionId: string;
|
||||
} | {
|
||||
action: "publish";
|
||||
versionId: string;
|
||||
reason: string;
|
||||
} | {
|
||||
action: "rollback";
|
||||
configId: string;
|
||||
targetVersion: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type ProviderRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
provider_type: ModelProviderType;
|
||||
base_url: string | null;
|
||||
secret_ref: string;
|
||||
enabled: boolean;
|
||||
version_id: string;
|
||||
version_enabled: boolean;
|
||||
};
|
||||
|
||||
type Dependencies = {
|
||||
queryRows(sql: string, values?: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
|
||||
assertAllowedUrl(value: string): Promise<unknown>;
|
||||
probeAllowed(value: string, apiKey: string): Promise<number>;
|
||||
invalidateCatalog(): void;
|
||||
environment?: Readonly<Record<string, string | undefined>>;
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return Response.json(body, { status });
|
||||
}
|
||||
|
||||
function isImmutableProviderError(error: unknown) {
|
||||
return error instanceof Error && error.message.includes("model_provider_runtime_immutable");
|
||||
}
|
||||
|
||||
async function versionProvider(
|
||||
dependencies: Dependencies,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
) {
|
||||
const rows = await dependencies.queryRows(`
|
||||
select p.id,p.code,p.provider_type,p.base_url,p.secret_ref,p.enabled,
|
||||
v.id version_id,v.enabled version_enabled
|
||||
from public.model_config_versions v
|
||||
join public.model_providers p on p.id=v.provider_id
|
||||
where ${where}
|
||||
`, values);
|
||||
return (rows[0] as ProviderRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
async function runnableProvider(
|
||||
provider: ProviderRow,
|
||||
dependencies: Dependencies,
|
||||
) {
|
||||
if (!provider.enabled || !provider.version_enabled) return null;
|
||||
const apiKey = modelProviderSecretValue({
|
||||
code: provider.code,
|
||||
providerType: provider.provider_type,
|
||||
secretRef: provider.secret_ref,
|
||||
}, dependencies.environment ?? process.env);
|
||||
if (!apiKey) return null;
|
||||
if (provider.provider_type === "openai-compatible") {
|
||||
await dependencies.assertAllowedUrl(provider.base_url ?? "");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function probeAndRecord(
|
||||
provider: ProviderRow,
|
||||
actorUserId: string,
|
||||
requestId: string,
|
||||
dependencies: Dependencies,
|
||||
) {
|
||||
const apiKey = await runnableProvider(provider, dependencies);
|
||||
if (!apiKey) return { recorded: false, status: 0, success: false };
|
||||
let status = 0;
|
||||
try {
|
||||
status = await dependencies.probeAllowed(modelProviderModelsUrl({
|
||||
providerType: provider.provider_type,
|
||||
baseUrl: provider.base_url,
|
||||
}), apiKey);
|
||||
} catch {
|
||||
status = 0;
|
||||
}
|
||||
await dependencies.queryRows(
|
||||
"select public.admin_record_model_connection_test($1,$2,$3,$4) id",
|
||||
[actorUserId, provider.version_id, status, requestId],
|
||||
);
|
||||
return { recorded: true, status, success: modelConnectionTestSucceeded(status) };
|
||||
}
|
||||
|
||||
export async function handleAdminModelMutation(
|
||||
action: AdminModelMutation,
|
||||
context: Readonly<{ actorUserId: string; requestId: string }>,
|
||||
dependencies: Dependencies,
|
||||
) {
|
||||
if (action.action === "saveProvider") {
|
||||
const secretRef = expectedModelProviderSecretRef(action.code, action.providerType);
|
||||
if (action.secretRef && action.secretRef !== secretRef) {
|
||||
return json({ error: "模型供应商密钥引用不受允许" }, 400);
|
||||
}
|
||||
if (action.providerType === "openai-compatible") {
|
||||
await dependencies.assertAllowedUrl(action.baseUrl ?? "");
|
||||
}
|
||||
if (action.enabled && !modelProviderSecretValue({
|
||||
code: action.code,
|
||||
providerType: action.providerType,
|
||||
secretRef,
|
||||
}, dependencies.environment ?? process.env)) {
|
||||
return json({ error: "模型供应商密钥未配置" }, 409);
|
||||
}
|
||||
let rows: readonly Record<string, unknown>[];
|
||||
try {
|
||||
rows = await dependencies.queryRows(
|
||||
"select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) id",
|
||||
[context.actorUserId, action.id ?? null, action.code, action.name, action.providerType,
|
||||
action.providerType === "openai" ? null : action.baseUrl ?? null,
|
||||
secretRef, action.enabled, action.reason, context.requestId],
|
||||
);
|
||||
} catch (error) {
|
||||
if (isImmutableProviderError(error)) {
|
||||
return json({
|
||||
error: "已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",
|
||||
code: "model_provider_runtime_immutable",
|
||||
}, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
|
||||
}
|
||||
|
||||
if (action.action === "saveDraft") {
|
||||
const rows = await dependencies.queryRows(
|
||||
"select public.admin_save_model_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb,$17,$18) id",
|
||||
[context.actorUserId, action.modelId, action.versionId ?? null, action.providerId, action.label,
|
||||
action.description, action.providerModel, action.modelTier, action.creditCost, action.contextWindow ?? null,
|
||||
action.inputCostMicrousdPerMillion, action.outputCostMicrousdPerMillion, action.enabled, action.isDefault,
|
||||
action.fallbackModelId ?? null, JSON.stringify(action.settings), action.reason, context.requestId],
|
||||
);
|
||||
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
|
||||
}
|
||||
|
||||
if (action.action === "test") {
|
||||
const provider = await versionProvider(
|
||||
dependencies,
|
||||
"v.id=$1 and v.status in ('draft','published','retired')",
|
||||
[action.versionId],
|
||||
);
|
||||
if (!provider) return json({ error: "模型版本不存在" }, 404);
|
||||
const result = await probeAndRecord(provider, context.actorUserId, context.requestId, dependencies);
|
||||
if (!result.recorded) return json({ error: "模型版本或供应商未启用,或部署密钥不可用" }, 409);
|
||||
return json({
|
||||
data: {
|
||||
id: provider.version_id,
|
||||
reachable: result.success,
|
||||
status: result.status,
|
||||
secretConfigured: true,
|
||||
requestId: context.requestId,
|
||||
},
|
||||
}, result.success ? 200 : 409);
|
||||
}
|
||||
|
||||
if (action.action === "publish") {
|
||||
const rows = await dependencies.queryRows(
|
||||
"select public.admin_publish_model($1,$2,$3,$4) id",
|
||||
[context.actorUserId, action.versionId, action.reason, context.requestId],
|
||||
);
|
||||
dependencies.invalidateCatalog();
|
||||
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
|
||||
}
|
||||
|
||||
const provider = await versionProvider(
|
||||
dependencies,
|
||||
"v.config_id=$1 and v.version=$2 and v.status='retired'",
|
||||
[action.configId, action.targetVersion],
|
||||
);
|
||||
if (!provider) return json({ error: "回滚版本不存在" }, 404);
|
||||
const result = await probeAndRecord(provider, context.actorUserId, context.requestId, dependencies);
|
||||
if (!result.recorded) return json({ error: "回滚版本或供应商未启用,或部署密钥不可用" }, 409);
|
||||
if (!result.success) {
|
||||
return json({
|
||||
error: "回滚版本运行时连接测试失败",
|
||||
data: { status: result.status, requestId: context.requestId },
|
||||
}, 409);
|
||||
}
|
||||
const rows = await dependencies.queryRows(
|
||||
"select public.admin_rollback_model($1,$2,$3,$4,$5) id",
|
||||
[context.actorUserId, action.configId, action.targetVersion, action.reason, context.requestId],
|
||||
);
|
||||
dependencies.invalidateCatalog();
|
||||
return json({ data: { id: rows[0]!.id, requestId: context.requestId } });
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export async function reserveConsultationModel<
|
||||
>(
|
||||
modelId: string,
|
||||
resolveModel: (modelId: string) => Model | null,
|
||||
reserveCredit: () => Promise<Reservation>,
|
||||
reserveCredit: (model: Model) => Promise<Reservation>,
|
||||
) {
|
||||
const model = resolveModel(modelId);
|
||||
if (!model) return { status: "unavailable" } as const;
|
||||
@@ -13,6 +13,6 @@ export async function reserveConsultationModel<
|
||||
status: "reserved",
|
||||
model,
|
||||
usageModelId: model.id,
|
||||
reservation: await reserveCredit(),
|
||||
reservation: await reserveCredit(model),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import "server-only";
|
||||
|
||||
import type { MastraModelConfig } from "@mastra/core/llm";
|
||||
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { loadRuntimeFeatureFlags } from "@/lib/feature-flags";
|
||||
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
|
||||
import { modelProviderSecretValue, type ModelProviderType } from "@/lib/model-provider-policy";
|
||||
import {
|
||||
languageModelCatalog as environmentCatalog,
|
||||
type LanguageModelCatalog,
|
||||
type ResolvedLanguageModel,
|
||||
} from "@/mastra/model";
|
||||
|
||||
const cacheTtlMs = 15_000;
|
||||
const secretSettingNames = new Set([
|
||||
"apikey",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"databaseurl",
|
||||
"connectionstring",
|
||||
"password",
|
||||
"privatekey",
|
||||
"clientsecret",
|
||||
"secret",
|
||||
"token",
|
||||
]);
|
||||
|
||||
type PublishedModelRow = {
|
||||
model_id: string;
|
||||
version: number;
|
||||
label: string;
|
||||
description: string;
|
||||
provider_model: string;
|
||||
credit_cost: number;
|
||||
is_default: boolean;
|
||||
provider_code: string;
|
||||
provider_type: ModelProviderType;
|
||||
base_url: string | null;
|
||||
secret_ref: string;
|
||||
input_cost: string | number;
|
||||
output_cost: string | number;
|
||||
};
|
||||
|
||||
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
|
||||
type Circuit = { failures: number; openUntil: number };
|
||||
const state = globalThis as typeof globalThis & {
|
||||
jyotishaModelCatalogCache?: Cache;
|
||||
jyotishaModelCatalogCircuit?: Circuit;
|
||||
};
|
||||
|
||||
function isSecretSettingKey(key: string) {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return secretSettingNames.has(normalized)
|
||||
|| normalized.endsWith("apikey")
|
||||
|| normalized.endsWith("password")
|
||||
|| normalized.endsWith("authorization")
|
||||
|| normalized.endsWith("cookie")
|
||||
|| normalized.endsWith("databaseurl")
|
||||
|| normalized.endsWith("connectionstring")
|
||||
|| normalized.endsWith("privatekey")
|
||||
|| normalized.endsWith("clientsecret")
|
||||
|| normalized.endsWith("accesstoken")
|
||||
|| normalized.endsWith("refreshtoken")
|
||||
|| normalized.endsWith("authtoken")
|
||||
|| normalized.endsWith("bearertoken");
|
||||
}
|
||||
|
||||
export function modelSettingsContainSecrets(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(modelSettingsContainSecrets);
|
||||
if (!value || typeof value !== "object") return false;
|
||||
return Object.entries(value).some(([key, nested]) => isSecretSettingKey(key) || modelSettingsContainSecrets(nested));
|
||||
}
|
||||
|
||||
export function sanitizeModelSettings(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sanitizeModelSettings);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(Object.entries(value)
|
||||
.filter(([key]) => !isSecretSettingKey(key))
|
||||
.map(([key, nested]) => [key, sanitizeModelSettings(nested)]));
|
||||
}
|
||||
|
||||
function publicModel(model: ResolvedLanguageModel) {
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
creditCost: model.creditCost,
|
||||
isDefault: model.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
|
||||
const apiKey = modelProviderSecretValue({
|
||||
code: row.provider_code,
|
||||
providerType: row.provider_type,
|
||||
secretRef: row.secret_ref,
|
||||
});
|
||||
if (!apiKey) return null;
|
||||
|
||||
const model: MastraModelConfig = row.provider_type === "openai"
|
||||
? {
|
||||
providerId: "openai",
|
||||
modelId: row.provider_model.replace(/^openai\//, ""),
|
||||
apiKey,
|
||||
}
|
||||
: {
|
||||
providerId: row.provider_code,
|
||||
modelId: row.provider_model,
|
||||
url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(),
|
||||
apiKey,
|
||||
};
|
||||
|
||||
return {
|
||||
id: row.model_id,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
creditCost: row.credit_cost,
|
||||
isDefault: row.is_default,
|
||||
mode: row.provider_type === "openai" ? "openai" : "compatible",
|
||||
model,
|
||||
configVersion: row.version,
|
||||
inputCostMicrousdPerMillion: Number(row.input_cost),
|
||||
outputCostMicrousdPerMillion: Number(row.output_cost),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPublishedCatalog(): Promise<LanguageModelCatalog | null> {
|
||||
const rows = await queryAdminRows<PublishedModelRow>(`
|
||||
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
|
||||
p.code provider_code,p.provider_type,p.base_url,p.secret_ref,
|
||||
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost
|
||||
from public.model_config_versions v
|
||||
join public.model_configs c on c.id=v.config_id
|
||||
join public.model_providers p on p.id=v.provider_id
|
||||
where v.status='published' and v.enabled and p.enabled
|
||||
order by v.is_default desc,c.model_id
|
||||
`);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const models: ResolvedLanguageModel[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const model = await resolveRow(row);
|
||||
if (model) models.push(model);
|
||||
} catch {
|
||||
// Unsafe or unresolvable providers are excluded at the real runtime boundary.
|
||||
}
|
||||
}
|
||||
const defaults = models.filter((model) => model.isDefault);
|
||||
if (defaults.length !== 1) return null;
|
||||
return {
|
||||
models,
|
||||
publicModels: models.map(publicModel),
|
||||
defaultModelId: defaults[0]!.id,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number, maximum: number) {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0
|
||||
? Math.min(value, maximum)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
async function runtimeSafeEnvironmentCatalog(): Promise<LanguageModelCatalog> {
|
||||
const models: ResolvedLanguageModel[] = [];
|
||||
const issues = [...environmentCatalog.issues];
|
||||
for (const model of environmentCatalog.models) {
|
||||
if (model.mode !== "compatible") {
|
||||
models.push(model);
|
||||
continue;
|
||||
}
|
||||
const config = model.model;
|
||||
const url = typeof config === "object" && config !== null && "url" in config && typeof config.url === "string"
|
||||
? config.url
|
||||
: "";
|
||||
try {
|
||||
await assertAllowedModelProviderUrl(url);
|
||||
models.push(model);
|
||||
} catch {
|
||||
issues.push(`runtime_provider_unsafe:${model.id}`);
|
||||
}
|
||||
}
|
||||
const defaultModelId = models.some((model) => model.id === environmentCatalog.defaultModelId)
|
||||
? environmentCatalog.defaultModelId
|
||||
: null;
|
||||
if (!defaultModelId && !issues.includes("default_model_unavailable")) issues.push("default_model_unavailable");
|
||||
return { models, publicModels: models.map(publicModel), defaultModelId, issues };
|
||||
}
|
||||
|
||||
function recordCatalogFailure(threshold: number, cooldownMs: number) {
|
||||
const circuit = state.jyotishaModelCatalogCircuit ?? { failures: 0, openUntil: 0 };
|
||||
const failures = circuit.failures + 1;
|
||||
state.jyotishaModelCatalogCircuit = {
|
||||
failures,
|
||||
openUntil: failures >= threshold ? Date.now() + cooldownMs : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadLanguageModelCatalog(): Promise<LanguageModelCatalog> {
|
||||
const cached = state.jyotishaModelCatalogCache;
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.catalog;
|
||||
let catalog = await runtimeSafeEnvironmentCatalog();
|
||||
if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") {
|
||||
state.jyotishaModelCatalogCache = { expiresAt: Date.now() + cacheTtlMs, catalog };
|
||||
return catalog;
|
||||
}
|
||||
|
||||
let breakerEnabled = false;
|
||||
let threshold = 5;
|
||||
let cooldownMs = 300_000;
|
||||
try {
|
||||
const flags = await loadRuntimeFeatureFlags(["models.database_catalog", "models.circuit_breaker"]);
|
||||
if (flags.get("models.database_catalog")?.enabled) {
|
||||
const breaker = flags.get("models.circuit_breaker");
|
||||
breakerEnabled = Boolean(breaker?.enabled);
|
||||
threshold = positiveInteger(breaker?.config.failureThreshold, threshold, 100);
|
||||
cooldownMs = positiveInteger(breaker?.config.cooldownSeconds, 300, 3_600) * 1_000;
|
||||
const circuit = state.jyotishaModelCatalogCircuit ?? { failures: 0, openUntil: 0 };
|
||||
if (!breakerEnabled) state.jyotishaModelCatalogCircuit = { failures: 0, openUntil: 0 };
|
||||
if (!breakerEnabled || circuit.openUntil <= Date.now()) {
|
||||
const published = await readPublishedCatalog();
|
||||
if (published) {
|
||||
catalog = published;
|
||||
state.jyotishaModelCatalogCircuit = { failures: 0, openUntil: 0 };
|
||||
} else if (breakerEnabled) {
|
||||
recordCatalogFailure(threshold, cooldownMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (breakerEnabled) recordCatalogFailure(threshold, cooldownMs);
|
||||
}
|
||||
state.jyotishaModelCatalogCache = { expiresAt: Date.now() + cacheTtlMs, catalog };
|
||||
return catalog;
|
||||
}
|
||||
|
||||
async function readVersion(modelId: string, version: number) {
|
||||
const rows = await queryAdminRows<PublishedModelRow>(`
|
||||
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
|
||||
p.code provider_code,p.provider_type,p.base_url,p.secret_ref,
|
||||
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost
|
||||
from public.model_config_versions v
|
||||
join public.model_configs c on c.id=v.config_id
|
||||
join public.model_providers p on p.id=v.provider_id
|
||||
where c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled
|
||||
`, [modelId, version]);
|
||||
return rows[0] ? resolveRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
|
||||
if (configVersion !== null && configVersion !== undefined) {
|
||||
if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") return null;
|
||||
try {
|
||||
return await readVersion(modelId, configVersion);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
return catalog.models.find((model) => model.id === modelId) ?? null;
|
||||
}
|
||||
|
||||
export function invalidateLanguageModelCatalog() {
|
||||
delete state.jyotishaModelCatalogCache;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type ModelProviderType = "openai" | "openai-compatible";
|
||||
|
||||
export type ModelProviderSecret = Readonly<{
|
||||
code: string;
|
||||
providerType: ModelProviderType;
|
||||
secretRef: string;
|
||||
}>;
|
||||
|
||||
const fixedModelSecretEnvironmentNames = new Set([
|
||||
"OPENAI_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"LLM_API_KEY",
|
||||
]);
|
||||
|
||||
export function isAllowedModelSecretEnvironmentName(name: string) {
|
||||
return fixedModelSecretEnvironmentNames.has(name)
|
||||
|| /^MODEL_PROVIDER_[A-Z][A-Z0-9_]{0,63}_API_KEY$/.test(name);
|
||||
}
|
||||
|
||||
export function expectedModelProviderSecretRef(code: string, providerType: ModelProviderType) {
|
||||
if (providerType === "openai") return "env:OPENAI_API_KEY";
|
||||
if (code === "deepseek") return "env:DEEPSEEK_API_KEY";
|
||||
if (code === "legacy-compatible") return "env:LLM_API_KEY";
|
||||
return `env:MODEL_PROVIDER_${code.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
||||
}
|
||||
|
||||
export function modelProviderSecretValue(
|
||||
provider: ModelProviderSecret,
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
) {
|
||||
const expected = expectedModelProviderSecretRef(provider.code, provider.providerType);
|
||||
if (provider.secretRef !== expected) return "";
|
||||
const environmentName = expected.slice(4);
|
||||
if (!isAllowedModelSecretEnvironmentName(environmentName)) return "";
|
||||
return environment[environmentName]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function modelProviderModelsUrl(provider: Readonly<{
|
||||
providerType: ModelProviderType;
|
||||
baseUrl: string | null;
|
||||
}>) {
|
||||
if (provider.providerType === "openai") return "https://api.openai.com/v1/models";
|
||||
const url = new URL(provider.baseUrl ?? "");
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
const basePath = url.pathname.replace(/\/+$/, "");
|
||||
url.pathname = basePath.endsWith("/models") ? basePath : `${basePath}/models`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function modelConnectionTestSucceeded(status: number) {
|
||||
return status >= 200 && status <= 299;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const publicLanguageModelSchema = z.object({
|
||||
id: z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9._-]*$/),
|
||||
label: z.string().trim().min(1).max(60),
|
||||
description: z.string().trim().max(100),
|
||||
creditCost: z.literal(1),
|
||||
creditCost: z.number().int().positive(),
|
||||
isDefault: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
@@ -26,7 +26,7 @@ export type PublicLanguageModel = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly creditCost: 1;
|
||||
readonly creditCost: number;
|
||||
readonly isDefault: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
type SessionModelWrite = {
|
||||
// model_config_version is server-owned and pinned by the database trigger.
|
||||
readonly values: { readonly model_id: string };
|
||||
readonly sessionId: string;
|
||||
readonly userId: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MastraModelConfig } from "@mastra/core/llm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { isAllowedModelSecretEnvironmentName } from "@/lib/model-provider-policy";
|
||||
|
||||
type Environment = Readonly<Record<string, string | undefined>>;
|
||||
type LanguageModelMode = "openai" | "compatible";
|
||||
|
||||
@@ -8,13 +10,16 @@ export type PublicLanguageModel = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly creditCost: 1;
|
||||
readonly creditCost: number;
|
||||
readonly isDefault: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedLanguageModel = PublicLanguageModel & {
|
||||
readonly mode: LanguageModelMode;
|
||||
readonly model: MastraModelConfig;
|
||||
readonly configVersion?: number;
|
||||
readonly inputCostMicrousdPerMillion?: number;
|
||||
readonly outputCostMicrousdPerMillion?: number;
|
||||
};
|
||||
|
||||
export type LanguageModelCatalog = {
|
||||
@@ -25,14 +30,14 @@ export type LanguageModelCatalog = {
|
||||
};
|
||||
|
||||
const modelIdSchema = z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9._-]*$/);
|
||||
const apiKeyEnvironmentNameSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/);
|
||||
const apiKeyEnvironmentNameSchema = z.string().refine(isAllowedModelSecretEnvironmentName);
|
||||
const sharedCatalogFields = {
|
||||
id: modelIdSchema,
|
||||
label: z.string().trim().min(1).max(60),
|
||||
description: z.string().trim().max(100).default(""),
|
||||
apiKeyEnv: apiKeyEnvironmentNameSchema,
|
||||
model: z.string().trim().min(1).max(120),
|
||||
creditCost: z.literal(1),
|
||||
creditCost: z.number().int().positive(),
|
||||
};
|
||||
const catalogEntrySchema = z.discriminatedUnion("provider", [
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user