diff --git a/src/api/providers/fetchers/__tests__/chutes.spec.ts b/src/api/providers/fetchers/__tests__/chutes.spec.ts index b68903fde57..79ed0273837 100644 --- a/src/api/providers/fetchers/__tests__/chutes.spec.ts +++ b/src/api/providers/fetchers/__tests__/chutes.spec.ts @@ -212,4 +212,132 @@ describe("getChutesModels", () => { expect(models["test/no-tools-model"].supportsNativeTools).toBe(false) expect(models["test/no-tools-model"].defaultToolProtocol).toBeUndefined() }) + + it("should skip empty objects in API response and still process valid models", async () => { + const mockResponse = { + data: { + data: [ + { + id: "test/valid-model", + object: "model", + owned_by: "test", + created: 1234567890, + context_length: 128000, + max_model_len: 8192, + input_modalities: ["text"], + }, + {}, // Empty object - should be skipped + { + id: "test/another-valid-model", + object: "model", + context_length: 64000, + max_model_len: 4096, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Valid models should be processed + expect(models["test/valid-model"]).toBeDefined() + expect(models["test/valid-model"].contextWindow).toBe(128000) + expect(models["test/another-valid-model"]).toBeDefined() + expect(models["test/another-valid-model"].contextWindow).toBe(64000) + }) + + it("should skip models without id field", async () => { + const mockResponse = { + data: { + data: [ + { + // Missing id field + object: "model", + context_length: 128000, + max_model_len: 8192, + }, + { + id: "test/valid-model", + context_length: 64000, + max_model_len: 4096, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Only the valid model should be added + expect(models["test/valid-model"]).toBeDefined() + // Hardcoded models should still exist + expect(Object.keys(models).length).toBeGreaterThan(1) + }) + + it("should calculate maxTokens fallback when max_model_len is missing", async () => { + const mockResponse = { + data: { + data: [ + { + id: "test/no-max-len-model", + object: "model", + context_length: 100000, + // max_model_len is missing + input_modalities: ["text"], + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Should calculate maxTokens as 20% of contextWindow + expect(models["test/no-max-len-model"]).toBeDefined() + expect(models["test/no-max-len-model"].maxTokens).toBe(20000) // 100000 * 0.2 + expect(models["test/no-max-len-model"].contextWindow).toBe(100000) + }) + + it("should gracefully handle response with mixed valid and invalid items", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const mockResponse = { + data: { + data: [ + { + id: "test/valid-1", + context_length: 128000, + max_model_len: 8192, + }, + {}, // Empty - will be skipped + null, // Null - will be skipped + { + id: "", // Empty string id - will be skipped + context_length: 64000, + }, + { + id: "test/valid-2", + context_length: 256000, + max_model_len: 16384, + supported_features: ["tools"], + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Both valid models should be processed + expect(models["test/valid-1"]).toBeDefined() + expect(models["test/valid-2"]).toBeDefined() + expect(models["test/valid-2"].supportsNativeTools).toBe(true) + + consoleErrorSpy.mockRestore() + }) }) diff --git a/src/api/providers/fetchers/chutes.ts b/src/api/providers/fetchers/chutes.ts index d5334aa59bc..247d8f3c552 100644 --- a/src/api/providers/fetchers/chutes.ts +++ b/src/api/providers/fetchers/chutes.ts @@ -6,19 +6,22 @@ import { type ModelInfo, chutesModels } from "@roo-code/types" import { DEFAULT_HEADERS } from "../constants" // Chutes models endpoint follows OpenAI /models shape with additional fields. +// All fields are optional to allow graceful handling of incomplete API responses. const ChutesModelSchema = z.object({ - id: z.string(), + id: z.string().optional(), object: z.literal("model").optional(), owned_by: z.string().optional(), created: z.number().optional(), context_length: z.number().optional(), - max_model_len: z.number(), + max_model_len: z.number().optional(), input_modalities: z.array(z.string()).optional(), supported_features: z.array(z.string()).optional(), }) const ChutesModelsResponseSchema = z.object({ data: z.array(ChutesModelSchema) }) +type ChutesModelsResponse = z.infer + export async function getChutesModels(apiKey?: string): Promise> { const headers: Record = { ...DEFAULT_HEADERS } @@ -32,33 +35,49 @@ export async function getChutesModels(apiKey?: string): Promise = { ...chutesModels } try { - const response = await axios.get(url, { headers }) - const parsed = ChutesModelsResponseSchema.safeParse(response.data) - - if (parsed.success) { - for (const m of parsed.data.data) { - const contextWindow = m.context_length - - if (!contextWindow) { - continue - } - - const info: ModelInfo = { - maxTokens: m.max_model_len, - contextWindow, - supportsImages: (m.input_modalities || []).includes("image"), - supportsPromptCache: false, - supportsNativeTools: (m.supported_features || []).includes("tools"), - inputPrice: 0, - outputPrice: 0, - description: `Chutes AI model: ${m.id}`, - } - - // Union: dynamic models override hardcoded ones if they have the same ID. - models[m.id] = info + const response = await axios.get(url, { headers }) + const result = ChutesModelsResponseSchema.safeParse(response.data) + + // Graceful fallback: use parsed data if valid, otherwise fall back to raw response data. + // This mirrors the OpenRouter pattern for handling API responses with some invalid items. + const data = result.success ? result.data.data : response.data?.data + + if (!result.success) { + console.error(`Error parsing Chutes models response: ${JSON.stringify(result.error.format(), null, 2)}`) + } + + if (!data || !Array.isArray(data)) { + console.error("Chutes models response missing data array") + return models + } + + for (const m of data) { + // Skip items missing required fields (e.g., empty objects from API) + if (!m || typeof m.id !== "string" || !m.id) { + continue } - } else { - console.error(`Error parsing Chutes models: ${JSON.stringify(parsed.error.format(), null, 2)}`) + + const contextWindow = typeof m.context_length === "number" && Number.isFinite(m.context_length) ? m.context_length : undefined + const maxModelLen = typeof m.max_model_len === "number" && Number.isFinite(m.max_model_len) ? m.max_model_len : undefined + + // Skip models without valid context window information + if (!contextWindow) { + continue + } + + const info: ModelInfo = { + maxTokens: maxModelLen ?? Math.ceil(contextWindow * 0.2), + contextWindow, + supportsImages: (m.input_modalities || []).includes("image"), + supportsPromptCache: false, + supportsNativeTools: (m.supported_features || []).includes("tools"), + inputPrice: 0, + outputPrice: 0, + description: `Chutes AI model: ${m.id}`, + } + + // Union: dynamic models override hardcoded ones if they have the same ID. + models[m.id] = info } } catch (error) { console.error(`Error fetching Chutes models: ${error instanceof Error ? error.message : String(error)}`)