feat(models): secure catalog and pin model versions
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user