Feat: add env-driven LLM configuration and smoke test

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

查看文件

@@ -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 普通文件
查看文件

@@ -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.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<InvokeResult> {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
authorization: `Bearer ${ENV.llmApiKey}`,
},
body: JSON.stringify(payload),
});