Skip to content

Commit ce38822

Browse files
committed
test(litellm): cover preserveReasoning inference and message conversion branching
Add unit coverage for LITELLM_PRESERVE_REASONING_PATTERN matching per model family, getLiteLLMModels setting preserveReasoning on matched models, and LiteLLMHandler.createMessage branching between convertToR1Format and convertToOpenAiMessages based on info.preserveReasoning. Signed-off-by: daewoongoh <[email protected]>
1 parent 902d63c commit ce38822

3 files changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { LITELLM_PRESERVE_REASONING_PATTERN } from "../providers/lite-llm.js"
2+
3+
describe("LITELLM_PRESERVE_REASONING_PATTERN", () => {
4+
it("matches known DeepSeek reasoning aliases", () => {
5+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("deepseek-v4-flash")).toBe(true)
6+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("deepseek-v4-pro")).toBe(true)
7+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("deepseek/deepseek-reasoner")).toBe(true)
8+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("deepseek-v4-mini")).toBe(false)
9+
})
10+
11+
it("matches known MiMo reasoning aliases", () => {
12+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("mimo-v2.5")).toBe(true)
13+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("mimo-v2.5-pro")).toBe(true)
14+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("mimo-v2.6")).toBe(false)
15+
})
16+
17+
it("matches known Kimi K2 reasoning aliases across routed providers", () => {
18+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("bedrock/moonshot.kimi-k2-thinking")).toBe(true)
19+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("fireworks_ai/accounts/fireworks/models/kimi-k2p7-code")).toBe(
20+
true,
21+
)
22+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("kimi-k2.7-code")).toBe(true)
23+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("kimi-k2.6")).toBe(false)
24+
})
25+
26+
it("matches MiniMax M2/M3 aliases but not other MiniMax generations", () => {
27+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax-m2")).toBe(true)
28+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax.m3")).toBe(true)
29+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax-m2.5")).toBe(true)
30+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax-m2-highspeed")).toBe(true)
31+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax-m2-stable")).toBe(true)
32+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax-m4")).toBe(false)
33+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("minimax-m1")).toBe(false)
34+
})
35+
36+
it("matches GLM-4.7 but excludes the flash variants", () => {
37+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-4.7")).toBe(true)
38+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-4.7-flash")).toBe(false)
39+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-4.7-flashx")).toBe(false)
40+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-4.8")).toBe(false)
41+
})
42+
43+
it("matches GLM-5 variants but excludes the flash variant", () => {
44+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-5")).toBe(true)
45+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-5.1")).toBe(true)
46+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-5.2")).toBe(true)
47+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-5-turbo")).toBe(true)
48+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-5.1-turbo")).toBe(true)
49+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("glm-5-flash")).toBe(false)
50+
})
51+
52+
it("matches curated Qwen3 plus/max aliases used by opencode-go", () => {
53+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("qwen3.7-plus")).toBe(true)
54+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("qwen3.6-max")).toBe(true)
55+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("qwen3.5-plus")).toBe(false)
56+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("qwen3.7-mini")).toBe(false)
57+
})
58+
59+
it("does not match unrelated model names", () => {
60+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("gpt-4")).toBe(false)
61+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("claude-3-opus")).toBe(false)
62+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("")).toBe(false)
63+
})
64+
65+
it("matches when the fragment appears anywhere in a combined alias/routed-model string", () => {
66+
// Mirrors how litellm.ts calls it: `${modelName} ${litellmModelName}`
67+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("my-deepseek-alias deepseek/deepseek-reasoner")).toBe(true)
68+
expect(LITELLM_PRESERVE_REASONING_PATTERN.test("my-gpt4-alias openai/gpt-4")).toBe(false)
69+
})
70+
})

‎src/api/providers/__tests__/lite-llm.spec.ts‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,145 @@ describe("LiteLLMHandler", () => {
11241124
})
11251125
})
11261126

1127+
describe("preserveReasoning message conversion", () => {
1128+
const mockStream = {
1129+
async *[Symbol.asyncIterator]() {
1130+
yield {
1131+
choices: [{ delta: { content: "ok" } }],
1132+
usage: { prompt_tokens: 1, completion_tokens: 1 },
1133+
}
1134+
},
1135+
}
1136+
1137+
it("uses convertToR1Format (merging tool-result text) when the model info sets preserveReasoning", async () => {
1138+
const optionsWithReasoning: ApiHandlerOptions = {
1139+
...mockOptions,
1140+
litellmModelId: "deepseek-reasoner-alias",
1141+
}
1142+
handler = new LiteLLMHandler(optionsWithReasoning)
1143+
1144+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
1145+
id: "deepseek-reasoner-alias",
1146+
info: { ...litellmDefaultModelInfo, preserveReasoning: true },
1147+
})
1148+
1149+
const systemPrompt = "You are a helpful assistant"
1150+
const messages: Anthropic.Messages.MessageParam[] = [
1151+
{ role: "user", content: "Hello" },
1152+
{
1153+
role: "assistant",
1154+
content: [
1155+
{ type: "text", text: "I'll help." },
1156+
{ type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } },
1157+
],
1158+
// DeepSeek-style interleaved thinking: must be echoed back in the next request.
1159+
reasoning_content: "Let me check the file first.",
1160+
} as Anthropic.Messages.MessageParam & { reasoning_content: string },
1161+
{
1162+
role: "user",
1163+
content: [
1164+
{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" },
1165+
{ type: "text", text: "Thanks, continue." },
1166+
],
1167+
},
1168+
]
1169+
1170+
mockCreate.mockReturnValue({
1171+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
1172+
})
1173+
1174+
const generator = handler.createMessage(systemPrompt, messages)
1175+
for await (const _chunk of generator) {
1176+
// Consume
1177+
}
1178+
1179+
const createCall = mockCreate.mock.calls[0][0]
1180+
1181+
// convertToR1Format with mergeToolResultText folds the trailing text into the
1182+
// tool message instead of appending a separate user message.
1183+
const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool")
1184+
expect(toolMessage).toBeDefined()
1185+
expect(toolMessage.content).toBe("file contents\n\nThanks, continue.")
1186+
1187+
const trailingUserMessage = createCall.messages.find(
1188+
(msg: any) => msg.role === "user" && msg.content === "Thanks, continue.",
1189+
)
1190+
expect(trailingUserMessage).toBeUndefined()
1191+
1192+
// The whole point of routing through convertToR1Format: reasoning_content
1193+
// must survive on the assistant message so the model doesn't reject the
1194+
// follow-up request for missing prior reasoning.
1195+
const assistantMessage = createCall.messages.find(
1196+
(msg: any) => msg.role === "assistant" && msg.tool_calls?.length > 0,
1197+
)
1198+
expect(assistantMessage).toBeDefined()
1199+
expect(assistantMessage.reasoning_content).toBe("Let me check the file first.")
1200+
})
1201+
1202+
it("uses convertToOpenAiMessages (no merging) when the model info does not set preserveReasoning", async () => {
1203+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
1204+
id: litellmDefaultModelId,
1205+
info: { ...litellmDefaultModelInfo, preserveReasoning: undefined },
1206+
})
1207+
1208+
const systemPrompt = "You are a helpful assistant"
1209+
// Task.buildCleanConversationHistory() (src/core/task/Task.ts) already strips the
1210+
// reasoning content block before messages reach this handler when the model's
1211+
// preserveReasoning is not true, so no reasoning_content/reasoning block is present
1212+
// on the assistant message here — this input reflects what the handler actually
1213+
// receives in that case.
1214+
const messages: Anthropic.Messages.MessageParam[] = [
1215+
{ role: "user", content: "Hello" },
1216+
{
1217+
role: "assistant",
1218+
content: [
1219+
{ type: "text", text: "I'll help." },
1220+
{ type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } },
1221+
],
1222+
},
1223+
{
1224+
role: "user",
1225+
content: [
1226+
{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" },
1227+
{ type: "text", text: "Thanks, continue." },
1228+
],
1229+
},
1230+
]
1231+
1232+
mockCreate.mockReturnValue({
1233+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
1234+
})
1235+
1236+
const generator = handler.createMessage(systemPrompt, messages)
1237+
for await (const _chunk of generator) {
1238+
// Consume
1239+
}
1240+
1241+
const createCall = mockCreate.mock.calls[0][0]
1242+
1243+
const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool")
1244+
expect(toolMessage).toBeDefined()
1245+
expect(toolMessage.content).toBe("file contents")
1246+
1247+
const trailingUserMessage = createCall.messages.find(
1248+
(msg: any) =>
1249+
msg.role === "user" &&
1250+
(msg.content === "Thanks, continue." ||
1251+
(Array.isArray(msg.content) &&
1252+
msg.content.some((part: any) => part.text === "Thanks, continue."))),
1253+
)
1254+
expect(trailingUserMessage).toBeDefined()
1255+
1256+
// No reasoning_content is sent to the API in this branch, matching what
1257+
// buildCleanConversationHistory already stripped upstream.
1258+
const assistantMessage = createCall.messages.find(
1259+
(msg: any) => msg.role === "assistant" && msg.tool_calls?.length > 0,
1260+
)
1261+
expect(assistantMessage).toBeDefined()
1262+
expect(assistantMessage.reasoning_content).toBeUndefined()
1263+
})
1264+
})
1265+
11271266
describe("session ID header", () => {
11281267
const mockStream = {
11291268
async *[Symbol.asyncIterator]() {

‎src/api/providers/fetchers/__tests__/litellm.spec.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,4 +697,92 @@ describe("getLiteLLMModels", () => {
697697
description: "model-with-only-max-output-tokens via LiteLLM proxy",
698698
})
699699
})
700+
701+
describe("preserveReasoning inference", () => {
702+
it("sets preserveReasoning: true when the routed model matches a known reasoning family", async () => {
703+
const mockResponse = {
704+
data: {
705+
data: [
706+
{
707+
model_name: "deepseek-reasoner-alias",
708+
model_info: {
709+
max_tokens: 8192,
710+
max_input_tokens: 128000,
711+
},
712+
litellm_params: {
713+
model: "deepseek/deepseek-reasoner",
714+
},
715+
},
716+
{
717+
model_name: "kimi-k2-thinking",
718+
model_info: {
719+
max_tokens: 8192,
720+
max_input_tokens: 128000,
721+
},
722+
litellm_params: {
723+
model: "bedrock/moonshot.kimi-k2-thinking",
724+
},
725+
},
726+
],
727+
},
728+
}
729+
730+
mockedAxios.get.mockResolvedValue(mockResponse)
731+
732+
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
733+
734+
expect(result["deepseek-reasoner-alias"]).toMatchObject({ preserveReasoning: true })
735+
expect(result["kimi-k2-thinking"]).toMatchObject({ preserveReasoning: true })
736+
})
737+
738+
it("omits preserveReasoning when the routed model does not match a known reasoning family", async () => {
739+
const mockResponse = {
740+
data: {
741+
data: [
742+
{
743+
model_name: "gpt-4-turbo",
744+
model_info: {
745+
max_tokens: 8192,
746+
max_input_tokens: 128000,
747+
},
748+
litellm_params: {
749+
model: "openai/gpt-4-turbo",
750+
},
751+
},
752+
],
753+
},
754+
}
755+
756+
mockedAxios.get.mockResolvedValue(mockResponse)
757+
758+
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
759+
760+
expect(result["gpt-4-turbo"]).not.toHaveProperty("preserveReasoning")
761+
})
762+
763+
it("matches against the model alias even when the routed model name does not match", async () => {
764+
const mockResponse = {
765+
data: {
766+
data: [
767+
{
768+
model_name: "glm-5.1-turbo",
769+
model_info: {
770+
max_tokens: 8192,
771+
max_input_tokens: 128000,
772+
},
773+
litellm_params: {
774+
model: "zai/some-custom-deployment",
775+
},
776+
},
777+
],
778+
},
779+
}
780+
781+
mockedAxios.get.mockResolvedValue(mockResponse)
782+
783+
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
784+
785+
expect(result["glm-5.1-turbo"]).toMatchObject({ preserveReasoning: true })
786+
})
787+
})
700788
})

0 commit comments

Comments
 (0)