diff --git a/deploy/README.md b/deploy/README.md index 78965b38..589819ad 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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= +OPENAI_API_KEY= -# Or an OpenAI-compatible provider: -LLM_BASE_URL=... -LLM_API_KEY=... -LLM_MODEL=... +# Legacy single-model OpenAI configuration remains supported: +# OPENAI_API_KEY= +# MASTRA_MODEL=openai/gpt-5-mini + +# Legacy single OpenAI-compatible provider remains supported: +# LLM_BASE_URL=https://provider.example/v1 +# LLM_API_KEY= +# LLM_MODEL=provider-model-id # Optional VedAstro official upstream; local fallback remains available: VEDASTRO_API_ENDPOINT=... diff --git a/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md b/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md index cf1025ee..edaadf92 100644 --- a/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md +++ b/docs/superpowers/plans/2026-07-17-multi-model-chat-selection.md @@ -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" ``` diff --git a/frontend/README.md b/frontend/README.md index 2b231d1e..856f30be 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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= +OPENAI_API_KEY= -# 方案 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= +# MASTRA_MODEL=openai/gpt-5-mini + +# 兼容旧的单个 OpenAI-compatible 配置 +# LLM_BASE_URL=https://your-provider.example/v1 +# LLM_API_KEY= +# 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 如何触发 diff --git a/frontend/src/mastra/model.ts b/frontend/src/mastra/model.ts index 6d1afcd4..d6688a10 100644 --- a/frontend/src/mastra/model.ts +++ b/frontend/src/mastra/model.ts @@ -1,69 +1,244 @@ import type { MastraModelConfig } from "@mastra/core/llm"; +import { z } from "zod"; +type Environment = Readonly>; 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; + +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(); + 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 : "未配置可用的语言模型"; } diff --git a/frontend/tests/model-catalog.test.ts b/frontend/tests/model-catalog.test.ts new file mode 100644 index 00000000..7b36c7db --- /dev/null +++ b/frontend/tests/model-catalog.test.ts @@ -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); +});