Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/api/providers/fetchers/__tests__/chutes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
75 changes: 47 additions & 28 deletions src/api/providers/fetchers/chutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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)}`)
Expand Down
Loading