Procházet zdrojové kódy

fix(chutes): add graceful fallback for model parsing (#10279)

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
Hannes Rudolph před 1 týdnem
rodič
revize
f462eeb80c

+ 128 - 0
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()
+	})
 })

+ 47 - 28
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<typeof ChutesModelsResponseSchema>
+
 export async function getChutesModels(apiKey?: string): Promise<Record<string, ModelInfo>> {
 	const headers: Record<string, string> = { ...DEFAULT_HEADERS }
 
@@ -32,33 +35,49 @@ export async function getChutesModels(apiKey?: string): Promise<Record<string, M
 	const models: Record<string, ModelInfo> = { ...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<ChutesModelsResponse>(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)}`)