Skip to content

Commit df6e41c

Browse files
committed
Add tests for litellm config changes
1 parent f91ce13 commit df6e41c

File tree

5 files changed

+992
-0
lines changed

5 files changed

+992
-0
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import axios from "axios"
2+
import { getLiteLLMModels } from "../litellm"
3+
import { COMPUTER_USE_MODELS } from "../../../../shared/api"
4+
5+
// Mock axios
6+
jest.mock("axios")
7+
const mockedAxios = axios as jest.Mocked<typeof axios>
8+
9+
describe("getLiteLLMModels", () => {
10+
beforeEach(() => {
11+
jest.clearAllMocks()
12+
})
13+
14+
it("successfully fetches and formats LiteLLM models", async () => {
15+
const mockResponse = {
16+
data: {
17+
data: [
18+
{
19+
model_name: "claude-3-5-sonnet",
20+
model_info: {
21+
max_tokens: 4096,
22+
max_input_tokens: 200000,
23+
supports_vision: true,
24+
supports_prompt_caching: false,
25+
input_cost_per_token: 0.000003,
26+
output_cost_per_token: 0.000015,
27+
},
28+
litellm_params: {
29+
model: "anthropic/claude-3.5-sonnet",
30+
},
31+
},
32+
{
33+
model_name: "gpt-4-turbo",
34+
model_info: {
35+
max_tokens: 8192,
36+
max_input_tokens: 128000,
37+
supports_vision: false,
38+
supports_prompt_caching: false,
39+
input_cost_per_token: 0.00001,
40+
output_cost_per_token: 0.00003,
41+
},
42+
litellm_params: {
43+
model: "openai/gpt-4-turbo",
44+
},
45+
},
46+
],
47+
},
48+
}
49+
50+
mockedAxios.get.mockResolvedValue(mockResponse)
51+
52+
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
53+
54+
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/v1/model/info", {
55+
headers: {
56+
Authorization: "Bearer test-api-key",
57+
"Content-Type": "application/json",
58+
},
59+
timeout: 5000,
60+
})
61+
62+
expect(result).toEqual({
63+
"claude-3-5-sonnet": {
64+
maxTokens: 4096,
65+
contextWindow: 200000,
66+
supportsImages: true,
67+
supportsComputerUse: true,
68+
supportsPromptCache: false,
69+
inputPrice: 3,
70+
outputPrice: 15,
71+
description: "claude-3-5-sonnet via LiteLLM proxy",
72+
},
73+
"gpt-4-turbo": {
74+
maxTokens: 8192,
75+
contextWindow: 128000,
76+
supportsImages: false,
77+
supportsComputerUse: false,
78+
supportsPromptCache: false,
79+
inputPrice: 10,
80+
outputPrice: 30,
81+
description: "gpt-4-turbo via LiteLLM proxy",
82+
},
83+
})
84+
})
85+
86+
it("makes request without authorization header when no API key provided", async () => {
87+
const mockResponse = {
88+
data: {
89+
data: [],
90+
},
91+
}
92+
93+
mockedAxios.get.mockResolvedValue(mockResponse)
94+
95+
await getLiteLLMModels("", "http://localhost:4000")
96+
97+
expect(mockedAxios.get).toHaveBeenCalledWith("http://localhost:4000/v1/model/info", {
98+
headers: {
99+
"Content-Type": "application/json",
100+
},
101+
timeout: 5000,
102+
})
103+
})
104+
105+
it("handles computer use models correctly", async () => {
106+
const computerUseModel = Array.from(COMPUTER_USE_MODELS)[0]
107+
const mockResponse = {
108+
data: {
109+
data: [
110+
{
111+
model_name: "test-computer-model",
112+
model_info: {
113+
max_tokens: 4096,
114+
max_input_tokens: 200000,
115+
supports_vision: true,
116+
},
117+
litellm_params: {
118+
model: `anthropic/${computerUseModel}`,
119+
},
120+
},
121+
],
122+
},
123+
}
124+
125+
mockedAxios.get.mockResolvedValue(mockResponse)
126+
127+
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
128+
129+
expect(result["test-computer-model"]).toEqual({
130+
maxTokens: 4096,
131+
contextWindow: 200000,
132+
supportsImages: true,
133+
supportsComputerUse: true,
134+
supportsPromptCache: false,
135+
inputPrice: undefined,
136+
outputPrice: undefined,
137+
description: "test-computer-model via LiteLLM proxy",
138+
})
139+
})
140+
141+
it("throws error for unexpected response format", async () => {
142+
const mockResponse = {
143+
data: {
144+
// Missing 'data' field
145+
models: [],
146+
},
147+
}
148+
149+
mockedAxios.get.mockResolvedValue(mockResponse)
150+
151+
await expect(getLiteLLMModels("test-api-key", "http://localhost:4000")).rejects.toThrow(
152+
"Failed to fetch LiteLLM models: Unexpected response format.",
153+
)
154+
})
155+
156+
it("throws detailed error for HTTP error responses", async () => {
157+
const axiosError = {
158+
response: {
159+
status: 401,
160+
statusText: "Unauthorized",
161+
},
162+
isAxiosError: true,
163+
}
164+
165+
mockedAxios.isAxiosError.mockReturnValue(true)
166+
mockedAxios.get.mockRejectedValue(axiosError)
167+
168+
await expect(getLiteLLMModels("invalid-key", "http://localhost:4000")).rejects.toThrow(
169+
"Failed to fetch LiteLLM models: 401 Unauthorized. Check base URL and API key.",
170+
)
171+
})
172+
173+
it("throws network error for request failures", async () => {
174+
const axiosError = {
175+
request: {},
176+
isAxiosError: true,
177+
}
178+
179+
mockedAxios.isAxiosError.mockReturnValue(true)
180+
mockedAxios.get.mockRejectedValue(axiosError)
181+
182+
await expect(getLiteLLMModels("test-api-key", "http://invalid-url")).rejects.toThrow(
183+
"Failed to fetch LiteLLM models: No response from server. Check LiteLLM server status and base URL.",
184+
)
185+
})
186+
187+
it("throws generic error for other failures", async () => {
188+
const genericError = new Error("Network timeout")
189+
190+
mockedAxios.isAxiosError.mockReturnValue(false)
191+
mockedAxios.get.mockRejectedValue(genericError)
192+
193+
await expect(getLiteLLMModels("test-api-key", "http://localhost:4000")).rejects.toThrow(
194+
"Failed to fetch LiteLLM models: Network timeout",
195+
)
196+
})
197+
198+
it("handles timeout parameter correctly", async () => {
199+
const mockResponse = { data: { data: [] } }
200+
mockedAxios.get.mockResolvedValue(mockResponse)
201+
202+
await getLiteLLMModels("test-api-key", "http://localhost:4000")
203+
204+
expect(mockedAxios.get).toHaveBeenCalledWith(
205+
"http://localhost:4000/v1/model/info",
206+
expect.objectContaining({
207+
timeout: 5000,
208+
}),
209+
)
210+
})
211+
212+
it("returns empty object when data array is empty", async () => {
213+
const mockResponse = {
214+
data: {
215+
data: [],
216+
},
217+
}
218+
219+
mockedAxios.get.mockResolvedValue(mockResponse)
220+
221+
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
222+
223+
expect(result).toEqual({})
224+
})
225+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { getModels } from "../modelCache"
2+
import { getLiteLLMModels } from "../litellm"
3+
import { getOpenRouterModels } from "../openrouter"
4+
import { getRequestyModels } from "../requesty"
5+
import { getGlamaModels } from "../glama"
6+
import { getUnboundModels } from "../unbound"
7+
8+
// Mock NodeCache to avoid cache interference
9+
jest.mock("node-cache", () => {
10+
return jest.fn().mockImplementation(() => ({
11+
get: jest.fn().mockReturnValue(undefined), // Always return cache miss
12+
set: jest.fn(),
13+
del: jest.fn(),
14+
}))
15+
})
16+
17+
// Mock fs/promises to avoid file system operations
18+
jest.mock("fs/promises", () => ({
19+
writeFile: jest.fn().mockResolvedValue(undefined),
20+
readFile: jest.fn().mockResolvedValue("{}"),
21+
mkdir: jest.fn().mockResolvedValue(undefined),
22+
}))
23+
24+
// Mock all the model fetchers
25+
jest.mock("../litellm")
26+
jest.mock("../openrouter")
27+
jest.mock("../requesty")
28+
jest.mock("../glama")
29+
jest.mock("../unbound")
30+
31+
const mockGetLiteLLMModels = getLiteLLMModels as jest.MockedFunction<typeof getLiteLLMModels>
32+
const mockGetOpenRouterModels = getOpenRouterModels as jest.MockedFunction<typeof getOpenRouterModels>
33+
const mockGetRequestyModels = getRequestyModels as jest.MockedFunction<typeof getRequestyModels>
34+
const mockGetGlamaModels = getGlamaModels as jest.MockedFunction<typeof getGlamaModels>
35+
const mockGetUnboundModels = getUnboundModels as jest.MockedFunction<typeof getUnboundModels>
36+
37+
describe("getModels with new GetModelsOptions", () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks()
40+
})
41+
42+
it("calls getLiteLLMModels with correct parameters", async () => {
43+
const mockModels = {
44+
"claude-3-sonnet": {
45+
maxTokens: 4096,
46+
contextWindow: 200000,
47+
supportsPromptCache: false,
48+
description: "Claude 3 Sonnet via LiteLLM",
49+
},
50+
}
51+
mockGetLiteLLMModels.mockResolvedValue(mockModels)
52+
53+
const result = await getModels({
54+
provider: "litellm",
55+
apiKey: "test-api-key",
56+
baseUrl: "http://localhost:4000",
57+
})
58+
59+
expect(mockGetLiteLLMModels).toHaveBeenCalledWith("test-api-key", "http://localhost:4000")
60+
expect(result).toEqual(mockModels)
61+
})
62+
63+
it("calls getOpenRouterModels for openrouter provider", async () => {
64+
const mockModels = {
65+
"openrouter/model": {
66+
maxTokens: 8192,
67+
contextWindow: 128000,
68+
supportsPromptCache: false,
69+
description: "OpenRouter model",
70+
},
71+
}
72+
mockGetOpenRouterModels.mockResolvedValue(mockModels)
73+
74+
const result = await getModels({ provider: "openrouter" })
75+
76+
expect(mockGetOpenRouterModels).toHaveBeenCalled()
77+
expect(result).toEqual(mockModels)
78+
})
79+
80+
it("calls getRequestyModels with optional API key", async () => {
81+
const mockModels = {
82+
"requesty/model": {
83+
maxTokens: 4096,
84+
contextWindow: 8192,
85+
supportsPromptCache: false,
86+
description: "Requesty model",
87+
},
88+
}
89+
mockGetRequestyModels.mockResolvedValue(mockModels)
90+
91+
const result = await getModels({ provider: "requesty", apiKey: "requesty-key" })
92+
93+
expect(mockGetRequestyModels).toHaveBeenCalledWith("requesty-key")
94+
expect(result).toEqual(mockModels)
95+
})
96+
97+
it("calls getGlamaModels for glama provider", async () => {
98+
const mockModels = {
99+
"glama/model": {
100+
maxTokens: 4096,
101+
contextWindow: 8192,
102+
supportsPromptCache: false,
103+
description: "Glama model",
104+
},
105+
}
106+
mockGetGlamaModels.mockResolvedValue(mockModels)
107+
108+
const result = await getModels({ provider: "glama" })
109+
110+
expect(mockGetGlamaModels).toHaveBeenCalled()
111+
expect(result).toEqual(mockModels)
112+
})
113+
114+
it("calls getUnboundModels with optional API key", async () => {
115+
const mockModels = {
116+
"unbound/model": {
117+
maxTokens: 4096,
118+
contextWindow: 8192,
119+
supportsPromptCache: false,
120+
description: "Unbound model",
121+
},
122+
}
123+
mockGetUnboundModels.mockResolvedValue(mockModels)
124+
125+
const result = await getModels({ provider: "unbound", apiKey: "unbound-key" })
126+
127+
expect(mockGetUnboundModels).toHaveBeenCalledWith("unbound-key")
128+
expect(result).toEqual(mockModels)
129+
})
130+
131+
it("handles errors and re-throws them", async () => {
132+
const expectedError = new Error("LiteLLM connection failed")
133+
mockGetLiteLLMModels.mockRejectedValue(expectedError)
134+
135+
await expect(
136+
getModels({
137+
provider: "litellm",
138+
apiKey: "test-api-key",
139+
baseUrl: "http://localhost:4000",
140+
}),
141+
).rejects.toThrow("LiteLLM connection failed")
142+
})
143+
144+
it("validates exhaustive provider checking with unknown provider", async () => {
145+
// This test ensures TypeScript catches unknown providers at compile time
146+
// In practice, the discriminated union should prevent this at compile time
147+
const unknownProvider = "unknown" as any
148+
149+
await expect(
150+
getModels({
151+
provider: unknownProvider,
152+
}),
153+
).rejects.toThrow("Unknown provider: unknown")
154+
})
155+
})

0 commit comments

Comments
 (0)