Feat: add env-driven LLM configuration and smoke test
这个提交包含在:
@@ -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
|
||||
|
||||
|
||||
10
README.md
10
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 为主
|
||||
|
||||
@@ -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 前执行:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
28
scripts/llm-smoke.ts
普通文件
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 = {
|
||||
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),
|
||||
};
|
||||
|
||||
106
server/_core/llm.test.ts
普通文件
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 = () =>
|
||||
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<InvokeResult> {
|
||||
} = params;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model: "gemini-2.5-flash",
|
||||
model: ENV.llmModel,
|
||||
messages: messages.map(normalizeMessage),
|
||||
};
|
||||
|
||||
@@ -296,9 +296,12 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
payload.tool_choice = normalizedToolChoice;
|
||||
}
|
||||
|
||||
payload.max_tokens = 32768
|
||||
payload.max_tokens = ENV.llmMaxTokens;
|
||||
|
||||
if (ENV.llmEnableThinking && ENV.llmThinkingBudget > 0) {
|
||||
payload.thinking = {
|
||||
"budget_tokens": 128
|
||||
budget_tokens: ENV.llmThinkingBudget,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedResponseFormat = normalizeResponseFormat({
|
||||
@@ -316,7 +319,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${ENV.forgeApiKey}`,
|
||||
authorization: `Bearer ${ENV.llmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户