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

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
This commit is contained in:
Hannes Rudolph 2025-12-22 12:59:13 -07:00 committed by GitHub
parent 7fae76ec86
commit f462eeb80c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 171 additions and 24 deletions

View file

@ -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()
})
})

View file

@ -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)
const response = await axios.get<ChutesModelsResponse>(url, { headers })
const result = ChutesModelsResponseSchema.safeParse(response.data)
if (parsed.success) {
for (const m of parsed.data.data) {
const contextWindow = m.context_length
// 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 (!contextWindow) {
continue
}
if (!result.success) {
console.error(`Error parsing Chutes models response: ${JSON.stringify(result.error.format(), null, 2)}`)
}
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}`,
}
if (!data || !Array.isArray(data)) {
console.error("Chutes models response missing data array")
return models
}
// Union: dynamic models override hardcoded ones if they have the same ID.
models[m.id] = info
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)}`)