Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit f462eeb

Browse files
hannesrudolphroomote[bot]
andauthored
fix(chutes): add graceful fallback for model parsing (#10279)
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
1 parent 7fae76e commit f462eeb

2 files changed

Lines changed: 175 additions & 28 deletions

File tree

src/api/providers/fetchers/__tests__/chutes.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,132 @@ describe("getChutesModels", () => {
212212
expect(models["test/no-tools-model"].supportsNativeTools).toBe(false)
213213
expect(models["test/no-tools-model"].defaultToolProtocol).toBeUndefined()
214214
})
215+
216+
it("should skip empty objects in API response and still process valid models", async () => {
217+
const mockResponse = {
218+
data: {
219+
data: [
220+
{
221+
id: "test/valid-model",
222+
object: "model",
223+
owned_by: "test",
224+
created: 1234567890,
225+
context_length: 128000,
226+
max_model_len: 8192,
227+
input_modalities: ["text"],
228+
},
229+
{}, // Empty object - should be skipped
230+
{
231+
id: "test/another-valid-model",
232+
object: "model",
233+
context_length: 64000,
234+
max_model_len: 4096,
235+
},
236+
],
237+
},
238+
}
239+
240+
mockedAxios.get.mockResolvedValue(mockResponse)
241+
242+
const models = await getChutesModels("test-api-key")
243+
244+
// Valid models should be processed
245+
expect(models["test/valid-model"]).toBeDefined()
246+
expect(models["test/valid-model"].contextWindow).toBe(128000)
247+
expect(models["test/another-valid-model"]).toBeDefined()
248+
expect(models["test/another-valid-model"].contextWindow).toBe(64000)
249+
})
250+
251+
it("should skip models without id field", async () => {
252+
const mockResponse = {
253+
data: {
254+
data: [
255+
{
256+
// Missing id field
257+
object: "model",
258+
context_length: 128000,
259+
max_model_len: 8192,
260+
},
261+
{
262+
id: "test/valid-model",
263+
context_length: 64000,
264+
max_model_len: 4096,
265+
},
266+
],
267+
},
268+
}
269+
270+
mockedAxios.get.mockResolvedValue(mockResponse)
271+
272+
const models = await getChutesModels("test-api-key")
273+
274+
// Only the valid model should be added
275+
expect(models["test/valid-model"]).toBeDefined()
276+
// Hardcoded models should still exist
277+
expect(Object.keys(models).length).toBeGreaterThan(1)
278+
})
279+
280+
it("should calculate maxTokens fallback when max_model_len is missing", async () => {
281+
const mockResponse = {
282+
data: {
283+
data: [
284+
{
285+
id: "test/no-max-len-model",
286+
object: "model",
287+
context_length: 100000,
288+
// max_model_len is missing
289+
input_modalities: ["text"],
290+
},
291+
],
292+
},
293+
}
294+
295+
mockedAxios.get.mockResolvedValue(mockResponse)
296+
297+
const models = await getChutesModels("test-api-key")
298+
299+
// Should calculate maxTokens as 20% of contextWindow
300+
expect(models["test/no-max-len-model"]).toBeDefined()
301+
expect(models["test/no-max-len-model"].maxTokens).toBe(20000) // 100000 * 0.2
302+
expect(models["test/no-max-len-model"].contextWindow).toBe(100000)
303+
})
304+
305+
it("should gracefully handle response with mixed valid and invalid items", async () => {
306+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
307+
308+
const mockResponse = {
309+
data: {
310+
data: [
311+
{
312+
id: "test/valid-1",
313+
context_length: 128000,
314+
max_model_len: 8192,
315+
},
316+
{}, // Empty - will be skipped
317+
null, // Null - will be skipped
318+
{
319+
id: "", // Empty string id - will be skipped
320+
context_length: 64000,
321+
},
322+
{
323+
id: "test/valid-2",
324+
context_length: 256000,
325+
max_model_len: 16384,
326+
supported_features: ["tools"],
327+
},
328+
],
329+
},
330+
}
331+
332+
mockedAxios.get.mockResolvedValue(mockResponse)
333+
334+
const models = await getChutesModels("test-api-key")
335+
336+
// Both valid models should be processed
337+
expect(models["test/valid-1"]).toBeDefined()
338+
expect(models["test/valid-2"]).toBeDefined()
339+
expect(models["test/valid-2"].supportsNativeTools).toBe(true)
340+
341+
consoleErrorSpy.mockRestore()
342+
})
215343
})

src/api/providers/fetchers/chutes.ts

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ import { type ModelInfo, chutesModels } from "@roo-code/types"
66
import { DEFAULT_HEADERS } from "../constants"
77

88
// Chutes models endpoint follows OpenAI /models shape with additional fields.
9+
// All fields are optional to allow graceful handling of incomplete API responses.
910
const ChutesModelSchema = z.object({
10-
id: z.string(),
11+
id: z.string().optional(),
1112
object: z.literal("model").optional(),
1213
owned_by: z.string().optional(),
1314
created: z.number().optional(),
1415
context_length: z.number().optional(),
15-
max_model_len: z.number(),
16+
max_model_len: z.number().optional(),
1617
input_modalities: z.array(z.string()).optional(),
1718
supported_features: z.array(z.string()).optional(),
1819
})
1920

2021
const ChutesModelsResponseSchema = z.object({ data: z.array(ChutesModelSchema) })
2122

23+
type ChutesModelsResponse = z.infer<typeof ChutesModelsResponseSchema>
24+
2225
export async function getChutesModels(apiKey?: string): Promise<Record<string, ModelInfo>> {
2326
const headers: Record<string, string> = { ...DEFAULT_HEADERS }
2427

@@ -32,33 +35,49 @@ export async function getChutesModels(apiKey?: string): Promise<Record<string, M
3235
const models: Record<string, ModelInfo> = { ...chutesModels }
3336

3437
try {
35-
const response = await axios.get(url, { headers })
36-
const parsed = ChutesModelsResponseSchema.safeParse(response.data)
37-
38-
if (parsed.success) {
39-
for (const m of parsed.data.data) {
40-
const contextWindow = m.context_length
41-
42-
if (!contextWindow) {
43-
continue
44-
}
45-
46-
const info: ModelInfo = {
47-
maxTokens: m.max_model_len,
48-
contextWindow,
49-
supportsImages: (m.input_modalities || []).includes("image"),
50-
supportsPromptCache: false,
51-
supportsNativeTools: (m.supported_features || []).includes("tools"),
52-
inputPrice: 0,
53-
outputPrice: 0,
54-
description: `Chutes AI model: ${m.id}`,
55-
}
56-
57-
// Union: dynamic models override hardcoded ones if they have the same ID.
58-
models[m.id] = info
38+
const response = await axios.get<ChutesModelsResponse>(url, { headers })
39+
const result = ChutesModelsResponseSchema.safeParse(response.data)
40+
41+
// Graceful fallback: use parsed data if valid, otherwise fall back to raw response data.
42+
// This mirrors the OpenRouter pattern for handling API responses with some invalid items.
43+
const data = result.success ? result.data.data : response.data?.data
44+
45+
if (!result.success) {
46+
console.error(`Error parsing Chutes models response: ${JSON.stringify(result.error.format(), null, 2)}`)
47+
}
48+
49+
if (!data || !Array.isArray(data)) {
50+
console.error("Chutes models response missing data array")
51+
return models
52+
}
53+
54+
for (const m of data) {
55+
// Skip items missing required fields (e.g., empty objects from API)
56+
if (!m || typeof m.id !== "string" || !m.id) {
57+
continue
5958
}
60-
} else {
61-
console.error(`Error parsing Chutes models: ${JSON.stringify(parsed.error.format(), null, 2)}`)
59+
60+
const contextWindow = typeof m.context_length === "number" && Number.isFinite(m.context_length) ? m.context_length : undefined
61+
const maxModelLen = typeof m.max_model_len === "number" && Number.isFinite(m.max_model_len) ? m.max_model_len : undefined
62+
63+
// Skip models without valid context window information
64+
if (!contextWindow) {
65+
continue
66+
}
67+
68+
const info: ModelInfo = {
69+
maxTokens: maxModelLen ?? Math.ceil(contextWindow * 0.2),
70+
contextWindow,
71+
supportsImages: (m.input_modalities || []).includes("image"),
72+
supportsPromptCache: false,
73+
supportsNativeTools: (m.supported_features || []).includes("tools"),
74+
inputPrice: 0,
75+
outputPrice: 0,
76+
description: `Chutes AI model: ${m.id}`,
77+
}
78+
79+
// Union: dynamic models override hardcoded ones if they have the same ID.
80+
models[m.id] = info
6281
}
6382
} catch (error) {
6483
console.error(`Error fetching Chutes models: ${error instanceof Error ? error.message : String(error)}`)

0 commit comments

Comments
 (0)