Feat: add env-driven LLM configuration and smoke test

这个提交包含在:
cryptocommuniums-afk
2026-03-14 21:54:51 +08:00
父节点 ba35e50528
当前提交 f5ad0449a8
修改 8 个文件,包含 203 行新增9 行删除

查看文件

@@ -13,6 +13,14 @@ VITE_OAUTH_PORTAL_URL=
VITE_FRONTEND_FORGE_API_URL= VITE_FRONTEND_FORGE_API_URL=
VITE_FRONTEND_FORGE_API_KEY= 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 # Optional direct media URL override for browser builds
VITE_MEDIA_BASE_URL=/media VITE_MEDIA_BASE_URL=/media

查看文件

@@ -92,9 +92,19 @@ pnpm exec playwright install chromium
- `DATABASE_URL` - `DATABASE_URL`
- `JWT_SECRET` - `JWT_SECRET`
- `LLM_API_URL`
- `LLM_API_KEY`
- `LLM_MODEL`
- `MEDIA_SERVICE_URL` - `MEDIA_SERVICE_URL`
- `VITE_MEDIA_BASE_URL` - `VITE_MEDIA_BASE_URL`
LLM 烟雾测试:
```bash
pnpm test:llm
pnpm test:llm -- "你好,做个自我介绍"
```
## Notes ## Notes
- 浏览器兼容目标以 Chrome 为主 - 浏览器兼容目标以 Chrome 为主

查看文件

@@ -19,6 +19,7 @@
当前覆盖: 当前覆盖:
- Node/tRPC 路由输入校验与权限检查 - Node/tRPC 路由输入校验与权限检查
- LLM 模块请求配置与环境变量回退逻辑
- 媒体工具函数,例如录制时长格式化与码率选择 - 媒体工具函数,例如录制时长格式化与码率选择
### 3. Go 媒体服务测试 ### 3. Go 媒体服务测试
@@ -59,6 +60,21 @@ pnpm verify
4. `pnpm build` 4. `pnpm build`
5. `pnpm test:e2e` 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 ## Local browser prerequisites
首次运行 Playwright 前执行: 首次运行 Playwright 前执行:

查看文件

@@ -13,6 +13,7 @@
"test": "vitest run", "test": "vitest run",
"test:go": "cd media && go test ./... && go build ./...", "test:go": "cd media && go test ./... && go build ./...",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:llm": "tsx scripts/llm-smoke.ts",
"verify": "pnpm check && pnpm test && pnpm test:go && pnpm build && pnpm test:e2e", "verify": "pnpm check && pnpm test && pnpm test:go && pnpm build && pnpm test:e2e",
"db:push": "drizzle-kit generate && drizzle-kit migrate" "db:push": "drizzle-kit generate && drizzle-kit migrate"
}, },

28
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);
});

查看文件

@@ -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 = { export const ENV = {
appId: process.env.VITE_APP_ID ?? "", appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "", cookieSecret: process.env.JWT_SECRET ?? "",
@@ -7,4 +18,15 @@ export const ENV = {
isProduction: process.env.NODE_ENV === "production", isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", 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),
}; };

106
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",
});
});
});

查看文件

@@ -210,13 +210,13 @@ const normalizeToolChoice = (
}; };
const resolveApiUrl = () => const resolveApiUrl = () =>
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0 ENV.llmApiUrl && ENV.llmApiUrl.trim().length > 0
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions` ? ENV.llmApiUrl
: "https://forge.manus.im/v1/chat/completions"; : "https://forge.manus.im/v1/chat/completions";
const assertApiKey = () => { const assertApiKey = () => {
if (!ENV.forgeApiKey) { if (!ENV.llmApiKey) {
throw new Error("OPENAI_API_KEY is not configured"); throw new Error("LLM_API_KEY is not configured");
} }
}; };
@@ -280,7 +280,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
} = params; } = params;
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
model: "gemini-2.5-flash", model: ENV.llmModel,
messages: messages.map(normalizeMessage), messages: messages.map(normalizeMessage),
}; };
@@ -296,9 +296,12 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
payload.tool_choice = normalizedToolChoice; payload.tool_choice = normalizedToolChoice;
} }
payload.max_tokens = 32768 payload.max_tokens = ENV.llmMaxTokens;
payload.thinking = {
"budget_tokens": 128 if (ENV.llmEnableThinking && ENV.llmThinkingBudget > 0) {
payload.thinking = {
budget_tokens: ENV.llmThinkingBudget,
};
} }
const normalizedResponseFormat = normalizeResponseFormat({ const normalizedResponseFormat = normalizeResponseFormat({
@@ -316,7 +319,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
method: "POST", method: "POST",
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`, authorization: `Bearer ${ENV.llmApiKey}`,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });