feat: add server model catalog

This commit is contained in:
Jesse_Chen
2026-07-17 13:03:03 +08:00
parent 46bb61425c
commit cb24c179e3
5 changed files with 384 additions and 74 deletions
+13 -7
View File
@@ -72,14 +72,20 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
ADMIN_EMAILS=...
# Either OpenAI:
OPENAI_API_KEY=...
MASTRA_MODEL=...
# Recommended multi-model catalog. The JSON references server-only keys.
LLM_DEFAULT_MODEL_ID=deepseek-pro
LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]'
DEEPSEEK_API_KEY=<server-secret>
OPENAI_API_KEY=<server-secret>
# Or an OpenAI-compatible provider:
LLM_BASE_URL=...
LLM_API_KEY=...
LLM_MODEL=...
# Legacy single-model OpenAI configuration remains supported:
# OPENAI_API_KEY=<server-secret>
# MASTRA_MODEL=openai/gpt-5-mini
# Legacy single OpenAI-compatible provider remains supported:
# LLM_BASE_URL=https://provider.example/v1
# LLM_API_KEY=<server-secret>
# LLM_MODEL=provider-model-id
# Optional VedAstro official upstream; local fallback remains available:
VEDASTRO_API_ENDPOINT=...
@@ -27,7 +27,6 @@
**Files:**
- Modify: `frontend/src/mastra/model.ts`
- Create: `frontend/tests/model-catalog.test.ts`
- Modify: `frontend/.env.example`
- Modify: `frontend/README.md`
- Modify: `deploy/README.md`
@@ -132,12 +131,12 @@ Expected: all catalog tests PASS and TypeScript exits `0`.
- [ ] **Step 5: Document configuration**
Update `.env.example`, `frontend/README.md`, and `deploy/README.md` with `LLM_MODELS_JSON`, `LLM_DEFAULT_MODEL_ID`, one secret environment variable per provider, and the existing single-model fallback. Use redacted values only.
Update `frontend/README.md` and `deploy/README.md` with `LLM_MODELS_JSON`, `LLM_DEFAULT_MODEL_ID`, one secret environment variable per provider, and the existing single-model fallback. Use redacted values only. Do not add `frontend/.env.example`: the repository intentionally ignores all `.env*` files.
- [ ] **Step 6: Commit the catalog task**
```bash
git add frontend/src/mastra/model.ts frontend/tests/model-catalog.test.ts frontend/.env.example frontend/README.md deploy/README.md
git add frontend/src/mastra/model.ts frontend/tests/model-catalog.test.ts frontend/README.md deploy/README.md docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md
git commit -m "feat: add server model catalog"
```
+15 -12
View File
@@ -39,24 +39,27 @@ cp .env.example .env.local
# Python 占星计算服务
JYOTISH_API_BASE=http://127.0.0.1:5200
# 方案 A:默认 OpenAI
OPENAI_API_KEY=sk-...
MASTRA_MODEL=openai/gpt-5-mini
# 推荐:多模型目录。目录只保存路由元数据,Key 由 apiKeyEnv 引用。
LLM_DEFAULT_MODEL_ID=deepseek-pro
LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]'
DEEPSEEK_API_KEY=<server-secret>
OPENAI_API_KEY=<server-secret>
# 方案 B:任意 OpenAI-compatible 第三方模型
# 只要填写任一 LLM_* 项,应用便会优先使用本方案。
# Base URL 通常填写到 /v1,不要填写完整 /chat/completions 地址。
LLM_BASE_URL=https://your-provider.example/v1
LLM_API_KEY=your-secret-key
LLM_MODEL=your-model-id
# 可选:只作为 Mastra 内部标签,不影响请求地址
LLM_PROVIDER_ID=third-party
# 兼容旧的单模型 OpenAI 配置
# OPENAI_API_KEY=<server-secret>
# MASTRA_MODEL=openai/gpt-5-mini
# 兼容旧的单个 OpenAI-compatible 配置
# LLM_BASE_URL=https://your-provider.example/v1
# LLM_API_KEY=<server-secret>
# LLM_MODEL=your-model-id
# LLM_PROVIDER_ID=third-party
# 可选:部署目录与本仓结构不同时,显式指定 Mastra Skill 目录
# JYOTISH_SKILL_PATH=/absolute/path/to/yinduzhanxing/skills/jyotish-vedic-astrology
```
第三方端点必须兼容 OpenAI 的 Chat Completions 调用方式,并支持工具调用(function calling),否则 Agent 无法稳定调用占星计算工具。密钥只放在 `.env.local`**不要**加 `NEXT_PUBLIC_` 前缀,也不要提交到 Git。每次修改 `.env.local` 后重启 Next.js 开发服务器。
第三方端点必须兼容 OpenAI 的 Chat Completions 调用方式,并支持工具调用(function calling),否则 Agent 无法稳定调用占星计算工具。`LLM_MODELS_JSON` 只能填写服务端认可的固定地址和模型;浏览器只会得到模型 ID、名称、说明和点数。密钥只放在 `.env.local`**不要**加 `NEXT_PUBLIC_` 前缀,也不要提交到 Git。每次修改 `.env.local` 后重启 Next.js 开发服务器。
## Skill 如何触发
+227 -52
View File
@@ -1,69 +1,244 @@
import type { MastraModelConfig } from "@mastra/core/llm";
import { z } from "zod";
type Environment = Readonly<Record<string, string | undefined>>;
type LanguageModelMode = "openai" | "compatible";
type LanguageModelSettings = {
mode: LanguageModelMode;
model: MastraModelConfig;
configured: boolean;
missing: string[];
export type PublicLanguageModel = {
readonly id: string;
readonly label: string;
readonly description: string;
readonly creditCost: 1;
readonly isDefault: boolean;
};
function environmentValue(name: string) {
return process.env[name]?.trim() ?? "";
export type ResolvedLanguageModel = PublicLanguageModel & {
readonly mode: LanguageModelMode;
readonly model: MastraModelConfig;
};
export type LanguageModelCatalog = {
readonly models: readonly ResolvedLanguageModel[];
readonly publicModels: readonly PublicLanguageModel[];
readonly defaultModelId: string | null;
readonly issues: readonly string[];
};
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 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),
};
const catalogEntrySchema = z.discriminatedUnion("provider", [
z.object({
...sharedCatalogFields,
provider: z.literal("openai"),
}).strict(),
z.object({
...sharedCatalogFields,
provider: z.literal("openai-compatible"),
baseURL: z.string().url().refine((value) => value.startsWith("https://")),
}).strict(),
]);
type CatalogEntry = z.infer<typeof catalogEntrySchema>;
function environmentValue(environment: Environment, name: string) {
return environment[name]?.trim() ?? "";
}
/**
* Resolves either the default OpenAI model or a third-party endpoint that
* implements the OpenAI Chat Completions API. A supplied LLM_* value switches
* the app to compatible-provider mode, so the old OPENAI_* setup remains
* backwards compatible.
*/
function resolveLanguageModelSettings(): LanguageModelSettings {
const baseURL = environmentValue("LLM_BASE_URL");
const apiKey = environmentValue("LLM_API_KEY");
const modelId = environmentValue("LLM_MODEL");
const hasCompatibleSetting = Boolean(baseURL || apiKey || modelId);
if (hasCompatibleSetting) {
const missing = [
!baseURL && "LLM_BASE_URL",
!apiKey && "LLM_API_KEY",
!modelId && "LLM_MODEL",
].filter((value): value is string => Boolean(value));
return {
mode: "compatible",
configured: missing.length === 0,
missing,
model: {
// This is an internal label for Mastra. It does not need to match the
// provider's company name; the URL determines the actual endpoint.
providerId: environmentValue("LLM_PROVIDER_ID") || "third-party",
modelId: modelId || "not-configured",
url: baseURL || undefined,
apiKey: apiKey || undefined,
},
};
}
const openAIKey = environmentValue("OPENAI_API_KEY");
function publicModel(model: ResolvedLanguageModel): PublicLanguageModel {
return {
mode: "openai",
configured: Boolean(openAIKey),
missing: openAIKey ? [] : ["OPENAI_API_KEY"],
model: environmentValue("MASTRA_MODEL") || "openai/gpt-5-mini",
id: model.id,
label: model.label,
description: model.description,
creditCost: model.creditCost,
isDefault: model.isDefault,
};
}
export const languageModelSettings = resolveLanguageModelSettings();
function resolveCatalogEntry(
entry: CatalogEntry,
apiKey: string,
isDefault: boolean,
): ResolvedLanguageModel {
const shared = {
id: entry.id,
label: entry.label,
description: entry.description,
creditCost: entry.creditCost,
isDefault,
} as const;
export function languageModelConfigurationMessage() {
if (languageModelSettings.configured) return null;
switch (entry.provider) {
case "openai":
return {
...shared,
mode: "openai",
model: entry.model,
};
case "openai-compatible":
return {
...shared,
mode: "compatible",
model: {
providerId: entry.id,
modelId: entry.model,
url: entry.baseURL,
apiKey,
},
};
}
}
if (languageModelSettings.mode === "compatible") {
return `第三方模型配置不完整:${languageModelSettings.missing.join("、")}`;
function resolveExplicitCatalog(environment: Environment, rawCatalog: string): LanguageModelCatalog {
let parsed: unknown;
try {
parsed = JSON.parse(rawCatalog);
} catch {
return { models: [], publicModels: [], defaultModelId: null, issues: ["catalog_json_invalid"] };
}
return "未配置 OPENAI_API_KEY";
if (!Array.isArray(parsed)) {
return { models: [], publicModels: [], defaultModelId: null, issues: ["catalog_not_array"] };
}
const defaultModelId = environmentValue(environment, "LLM_DEFAULT_MODEL_ID");
const issues: string[] = [];
const seenIds = new Set<string>();
const models: ResolvedLanguageModel[] = [];
parsed.forEach((value, index) => {
const parsedEntry = catalogEntrySchema.safeParse(value);
if (!parsedEntry.success) {
issues.push(`catalog_entry_invalid:${index}`);
return;
}
if (seenIds.has(parsedEntry.data.id)) {
issues.push(`catalog_entry_duplicate:${index}`);
return;
}
seenIds.add(parsedEntry.data.id);
const apiKey = environmentValue(environment, parsedEntry.data.apiKeyEnv);
if (!apiKey) {
issues.push(`catalog_entry_secret_missing:${index}`);
return;
}
models.push(resolveCatalogEntry(parsedEntry.data, apiKey, parsedEntry.data.id === defaultModelId));
});
const resolvedDefault = models.some((model) => model.id === defaultModelId)
? defaultModelId
: null;
if (!resolvedDefault) issues.push("default_model_unavailable");
return {
models,
publicModels: models.map(publicModel),
defaultModelId: resolvedDefault,
issues,
};
}
function resolveLegacyCatalog(environment: Environment): LanguageModelCatalog {
const baseURL = environmentValue(environment, "LLM_BASE_URL");
const apiKey = environmentValue(environment, "LLM_API_KEY");
const modelId = environmentValue(environment, "LLM_MODEL");
const hasCompatibleSetting = Boolean(baseURL || apiKey || modelId);
if (hasCompatibleSetting) {
if (!baseURL || !apiKey || !modelId || !baseURL.startsWith("https://")) {
return {
models: [],
publicModels: [],
defaultModelId: null,
issues: ["legacy_compatible_incomplete"],
};
}
const model: ResolvedLanguageModel = {
id: "legacy-compatible",
label: modelId,
description: "当前默认模型",
creditCost: 1,
isDefault: true,
mode: "compatible",
model: {
providerId: environmentValue(environment, "LLM_PROVIDER_ID") || "third-party",
modelId,
url: baseURL,
apiKey,
},
};
return {
models: [model],
publicModels: [publicModel(model)],
defaultModelId: model.id,
issues: [],
};
}
const openAIKey = environmentValue(environment, "OPENAI_API_KEY");
if (!openAIKey) {
return { models: [], publicModels: [], defaultModelId: null, issues: ["model_not_configured"] };
}
const modelIdValue = environmentValue(environment, "MASTRA_MODEL") || "openai/gpt-5-mini";
const model: ResolvedLanguageModel = {
id: "legacy-openai",
label: modelIdValue.replace(/^openai\//, ""),
description: "当前默认模型",
creditCost: 1,
isDefault: true,
mode: "openai",
model: modelIdValue,
};
return {
models: [model],
publicModels: [publicModel(model)],
defaultModelId: model.id,
issues: [],
};
}
export function resolveLanguageModelCatalog(environment: Environment): LanguageModelCatalog {
const rawCatalog = environmentValue(environment, "LLM_MODELS_JSON");
return rawCatalog
? resolveExplicitCatalog(environment, rawCatalog)
: resolveLegacyCatalog(environment);
}
export const languageModelCatalog = resolveLanguageModelCatalog(process.env);
export function resolveLanguageModel(modelId: string) {
return languageModelCatalog.models.find((model) => model.id === modelId) ?? null;
}
export function defaultLanguageModel() {
const defaultModelId = languageModelCatalog.defaultModelId;
return defaultModelId ? resolveLanguageModel(defaultModelId) : null;
}
export function publicLanguageModelCatalog() {
return {
models: languageModelCatalog.publicModels,
defaultModelId: languageModelCatalog.defaultModelId,
};
}
const configuredDefaultModel = defaultLanguageModel();
export const languageModelSettings = {
mode: configuredDefaultModel?.mode ?? "openai",
model: configuredDefaultModel?.model ?? "openai/gpt-5-mini",
configured: Boolean(configuredDefaultModel),
missing: languageModelCatalog.issues,
} as const;
export function languageModelConfigurationMessage() {
return configuredDefaultModel ? null : "未配置可用的语言模型";
}
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveLanguageModelCatalog } from "../src/mastra/model.ts";
const configuredModels = [
{
id: "deepseek-pro",
label: "DeepSeek V4 Pro",
description: "复杂分析",
provider: "openai-compatible",
baseURL: "https://api.deepseek.com",
apiKeyEnv: "DEEPSEEK_API_KEY",
model: "deepseek-v4-pro",
creditCost: 1,
},
{
id: "gpt-mini",
label: "ChatGPT Mini",
description: "均衡响应",
provider: "openai",
apiKeyEnv: "OPENAI_API_KEY",
model: "openai/gpt-5-mini",
creditCost: 1,
},
] as const;
test("resolves configured models while returning sanitized public metadata", () => {
// Given
const environment = {
LLM_DEFAULT_MODEL_ID: "deepseek-pro",
LLM_MODELS_JSON: JSON.stringify(configuredModels),
DEEPSEEK_API_KEY: "deepseek-secret",
OPENAI_API_KEY: "openai-secret",
};
// When
const catalog = resolveLanguageModelCatalog(environment);
// Then
assert.equal(catalog.defaultModelId, "deepseek-pro");
assert.deepEqual(catalog.publicModels[0], {
id: "deepseek-pro",
label: "DeepSeek V4 Pro",
description: "复杂分析",
creditCost: 1,
isDefault: true,
});
assert.equal(JSON.stringify(catalog.publicModels).includes("secret"), false);
assert.equal(JSON.stringify(catalog.publicModels).includes("baseURL"), false);
assert.equal(catalog.models[1]?.model, "openai/gpt-5-mini");
});
test("excludes an invalid catalog entry without leaking its secret", () => {
// Given
const environment = {
LLM_DEFAULT_MODEL_ID: "gpt-mini",
LLM_MODELS_JSON: JSON.stringify([
configuredModels[1],
{
...configuredModels[0],
id: "broken model",
baseURL: "http://api.deepseek.com",
},
]),
OPENAI_API_KEY: "openai-secret",
DEEPSEEK_API_KEY: "must-not-appear",
};
// When
const catalog = resolveLanguageModelCatalog(environment);
// Then
assert.deepEqual(catalog.models.map((model) => model.id), ["gpt-mini"]);
assert.equal(catalog.issues.length, 1);
assert.equal(JSON.stringify(catalog.issues).includes("must-not-appear"), false);
});
test("does not choose an undeclared default model", () => {
// Given
const environment = {
LLM_DEFAULT_MODEL_ID: "removed-model",
LLM_MODELS_JSON: JSON.stringify([configuredModels[1]]),
OPENAI_API_KEY: "openai-secret",
};
// When
const catalog = resolveLanguageModelCatalog(environment);
// Then
assert.equal(catalog.defaultModelId, null);
assert.equal(catalog.issues.includes("default_model_unavailable"), true);
});
test("derives the shipped compatible-provider configuration when no catalog exists", () => {
// Given
const environment = {
LLM_BASE_URL: "https://api.deepseek.com",
LLM_API_KEY: "legacy-secret",
LLM_MODEL: "deepseek-v4-pro",
LLM_PROVIDER_ID: "deepseek",
};
// When
const catalog = resolveLanguageModelCatalog(environment);
// Then
assert.equal(catalog.defaultModelId, "legacy-compatible");
assert.equal(catalog.models[0]?.id, "legacy-compatible");
assert.equal(catalog.publicModels[0]?.label, "deepseek-v4-pro");
assert.equal(JSON.stringify(catalog.publicModels).includes("legacy-secret"), false);
});
test("reports an incomplete legacy provider without inventing a model", () => {
// Given
const environment = {
LLM_BASE_URL: "https://api.deepseek.com",
LLM_MODEL: "deepseek-v4-pro",
};
// When
const catalog = resolveLanguageModelCatalog(environment);
// Then
assert.equal(catalog.models.length, 0);
assert.equal(catalog.defaultModelId, null);
assert.equal(catalog.issues.includes("legacy_compatible_incomplete"), true);
});