From f5ad0449a8c1bf589b65f9865de23a2d460dada9 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Sat, 14 Mar 2026 21:54:51 +0800 Subject: [PATCH] Feat: add env-driven LLM configuration and smoke test --- .env.example | 8 +++ README.md | 10 ++++ docs/testing.md | 16 ++++++ package.json | 1 + scripts/llm-smoke.ts | 28 +++++++++++ server/_core/env.ts | 22 ++++++++ server/_core/llm.test.ts | 106 +++++++++++++++++++++++++++++++++++++++ server/_core/llm.ts | 21 ++++---- 8 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 scripts/llm-smoke.ts create mode 100644 server/_core/llm.test.ts diff --git a/.env.example b/.env.example index 48fe731..d4de39a 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,14 @@ VITE_OAUTH_PORTAL_URL= VITE_FRONTEND_FORGE_API_URL= VITE_FRONTEND_FORGE_API_KEY= +# LLM chat completion endpoint +LLM_API_URL=https://one.hao.work/v1/chat/completions +LLM_API_KEY=replace-with-llm-api-key +LLM_MODEL=qwen3.5-plus +LLM_MAX_TOKENS=32768 +LLM_ENABLE_THINKING=0 +LLM_THINKING_BUDGET=128 + # Optional direct media URL override for browser builds VITE_MEDIA_BASE_URL=/media diff --git a/README.md b/README.md index 5ce0aba..a388009 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,19 @@ pnpm exec playwright install chromium - `DATABASE_URL` - `JWT_SECRET` +- `LLM_API_URL` +- `LLM_API_KEY` +- `LLM_MODEL` - `MEDIA_SERVICE_URL` - `VITE_MEDIA_BASE_URL` +LLM 烟雾测试: + +```bash +pnpm test:llm +pnpm test:llm -- "你好,做个自我介绍" +``` + ## Notes - 浏览器兼容目标以 Chrome 为主 diff --git a/docs/testing.md b/docs/testing.md index ec8fdc0..c15586d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -19,6 +19,7 @@ 当前覆盖: - Node/tRPC 路由输入校验与权限检查 +- LLM 模块请求配置与环境变量回退逻辑 - 媒体工具函数,例如录制时长格式化与码率选择 ### 3. Go 媒体服务测试 @@ -59,6 +60,21 @@ pnpm verify 4. `pnpm build` 5. `pnpm test:e2e` +## Live LLM smoke test + +使用真实 LLM 网关验证当前 `.env` 中的配置: + +```bash +pnpm test:llm +pnpm test:llm -- "你好,做个自我介绍" +``` + +说明: + +- 该命令会直接请求 `LLM_API_URL` +- 适合验证 `LLM_API_KEY`、`LLM_MODEL` 和网关连通性 +- 不建议纳入 `pnpm verify`,因为它依赖外部网络和真实密钥 + ## Local browser prerequisites 首次运行 Playwright 前执行: diff --git a/package.json b/package.json index 1b8149b..a9dc2db 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "vitest run", "test:go": "cd media && go test ./... && go build ./...", "test:e2e": "playwright test", + "test:llm": "tsx scripts/llm-smoke.ts", "verify": "pnpm check && pnpm test && pnpm test:go && pnpm build && pnpm test:e2e", "db:push": "drizzle-kit generate && drizzle-kit migrate" }, diff --git a/scripts/llm-smoke.ts b/scripts/llm-smoke.ts new file mode 100644 index 0000000..6fed14c --- /dev/null +++ b/scripts/llm-smoke.ts @@ -0,0 +1,28 @@ +import "dotenv/config"; +import { invokeLLM } from "../server/_core/llm"; + +async function main() { + const prompt = process.argv.slice(2).join(" ").trim() || "你好,做个自我介绍"; + const result = await invokeLLM({ + messages: [{ role: "user", content: prompt }], + }); + + const firstChoice = result.choices[0]; + const content = firstChoice?.message?.content; + + console.log(`model=${result.model}`); + console.log(`finish_reason=${firstChoice?.finish_reason ?? "unknown"}`); + + if (typeof content === "string") { + console.log(content); + return; + } + + console.log(JSON.stringify(content, null, 2)); +} + +main().catch(error => { + console.error("[LLM smoke test] failed"); + console.error(error); + process.exit(1); +}); diff --git a/server/_core/env.ts b/server/_core/env.ts index 2792b99..f1a24a1 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -1,3 +1,14 @@ +const parseInteger = (value: string | undefined, fallback: number) => { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const parseBoolean = (value: string | undefined, fallback: boolean) => { + if (value == null || value === "") return fallback; + return value === "1" || value.toLowerCase() === "true"; +}; + export const ENV = { appId: process.env.VITE_APP_ID ?? "", cookieSecret: process.env.JWT_SECRET ?? "", @@ -7,4 +18,15 @@ export const ENV = { isProduction: process.env.NODE_ENV === "production", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", + llmApiUrl: + process.env.LLM_API_URL ?? + (process.env.BUILT_IN_FORGE_API_URL + ? `${process.env.BUILT_IN_FORGE_API_URL.replace(/\/$/, "")}/v1/chat/completions` + : ""), + llmApiKey: + process.env.LLM_API_KEY ?? process.env.BUILT_IN_FORGE_API_KEY ?? "", + llmModel: process.env.LLM_MODEL ?? "gemini-2.5-flash", + llmMaxTokens: parseInteger(process.env.LLM_MAX_TOKENS, 32768), + llmEnableThinking: parseBoolean(process.env.LLM_ENABLE_THINKING, false), + llmThinkingBudget: parseInteger(process.env.LLM_THINKING_BUDGET, 128), }; diff --git a/server/_core/llm.test.ts b/server/_core/llm.test.ts new file mode 100644 index 0000000..6d862b0 --- /dev/null +++ b/server/_core/llm.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const ORIGINAL_ENV = { ...process.env }; + +const mockSuccessResponse = { + id: "chatcmpl-test", + created: 1, + model: "qwen3.5-plus", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "你好,我是测试响应。", + }, + finish_reason: "stop", + }, + ], +}; + +describe("invokeLLM", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...ORIGINAL_ENV }; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); + }); + + it("uses LLM_* environment variables for request config", async () => { + process.env.LLM_API_URL = "https://one.hao.work/v1/chat/completions"; + process.env.LLM_API_KEY = "test-key"; + process.env.LLM_MODEL = "qwen3.5-plus"; + process.env.LLM_MAX_TOKENS = "4096"; + process.env.LLM_ENABLE_THINKING = "0"; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockSuccessResponse, + }); + vi.stubGlobal("fetch", fetchMock); + + const { invokeLLM } = await import("./llm"); + await invokeLLM({ + messages: [{ role: "user", content: "你好" }], + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://one.hao.work/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + authorization: "Bearer test-key", + }), + }) + ); + + const [, request] = fetchMock.mock.calls[0] as [string, { body: string }]; + expect(JSON.parse(request.body)).toMatchObject({ + model: "qwen3.5-plus", + max_tokens: 4096, + messages: [{ role: "user", content: "你好" }], + }); + expect(JSON.parse(request.body)).not.toHaveProperty("thinking"); + }); + + it("falls back to legacy forge variables when LLM_* values are absent", async () => { + delete process.env.LLM_API_URL; + delete process.env.LLM_API_KEY; + delete process.env.LLM_MODEL; + delete process.env.LLM_MAX_TOKENS; + delete process.env.LLM_ENABLE_THINKING; + delete process.env.LLM_THINKING_BUDGET; + process.env.BUILT_IN_FORGE_API_URL = "https://forge.example.com"; + process.env.BUILT_IN_FORGE_API_KEY = "legacy-key"; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockSuccessResponse, + }); + vi.stubGlobal("fetch", fetchMock); + + const { invokeLLM } = await import("./llm"); + await invokeLLM({ + messages: [{ role: "user", content: "legacy" }], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://forge.example.com/v1/chat/completions", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer legacy-key", + }), + }) + ); + + const [, request] = fetchMock.mock.calls[0] as [string, { body: string }]; + expect(JSON.parse(request.body)).toMatchObject({ + model: "gemini-2.5-flash", + }); + }); +}); diff --git a/server/_core/llm.ts b/server/_core/llm.ts index 8ea4c4a..49f6cda 100644 --- a/server/_core/llm.ts +++ b/server/_core/llm.ts @@ -210,13 +210,13 @@ const normalizeToolChoice = ( }; const resolveApiUrl = () => - ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0 - ? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions` + ENV.llmApiUrl && ENV.llmApiUrl.trim().length > 0 + ? ENV.llmApiUrl : "https://forge.manus.im/v1/chat/completions"; const assertApiKey = () => { - if (!ENV.forgeApiKey) { - throw new Error("OPENAI_API_KEY is not configured"); + if (!ENV.llmApiKey) { + throw new Error("LLM_API_KEY is not configured"); } }; @@ -280,7 +280,7 @@ export async function invokeLLM(params: InvokeParams): Promise { } = params; const payload: Record = { - model: "gemini-2.5-flash", + model: ENV.llmModel, messages: messages.map(normalizeMessage), }; @@ -296,9 +296,12 @@ export async function invokeLLM(params: InvokeParams): Promise { payload.tool_choice = normalizedToolChoice; } - payload.max_tokens = 32768 - payload.thinking = { - "budget_tokens": 128 + payload.max_tokens = ENV.llmMaxTokens; + + if (ENV.llmEnableThinking && ENV.llmThinkingBudget > 0) { + payload.thinking = { + budget_tokens: ENV.llmThinkingBudget, + }; } const normalizedResponseFormat = normalizeResponseFormat({ @@ -316,7 +319,7 @@ export async function invokeLLM(params: InvokeParams): Promise { method: "POST", headers: { "content-type": "application/json", - authorization: `Bearer ${ENV.forgeApiKey}`, + authorization: `Bearer ${ENV.llmApiKey}`, }, body: JSON.stringify(payload), });