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_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
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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
普通文件
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
普通文件
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),
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户