Roo-Code/src/api/providers/fetchers/__tests__/roo.spec.ts
Roo Code 9ec2738217 fix(roo): update default model to anthropic/claude-sonnet-4.5
The previous default model xai/grok-code-fast-1 is no longer available from
the Roo Code Cloud /v1/models endpoint. Updated to anthropic/claude-sonnet-4.5
which is an active, non-deprecated model available through the Vercel provider.

Fixes EXT-717
2026-02-01 18:57:38 +00:00

1020 lines
24 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { getRooModels } from "../roo"
import { Package } from "../../../../shared/package"
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch as any
describe("getRooModels", () => {
const baseUrl = "https://api.roocode.com/proxy"
const apiKey = "test-api-key"
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("should fetch and parse models successfully", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "anthropic/claude-sonnet-4.5",
object: "model",
created: 1234567890,
owned_by: "anthropic",
name: "Claude Sonnet 4.5",
description: "Fast coding model",
context_window: 262144,
max_tokens: 16384,
type: "language",
tags: ["vision", "reasoning"],
pricing: {
input: "0.0001",
output: "0.0002",
input_cache_read: "0.00005",
input_cache_write: "0.0001",
},
deprecated: false,
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(mockFetch).toHaveBeenCalledWith(
"https://api.roocode.com/proxy/v1/models",
expect.objectContaining({
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
}),
}),
)
expect(models).toEqual({
"anthropic/claude-sonnet-4.5": {
maxTokens: 16384,
contextWindow: 262144,
supportsImages: true,
supportsReasoningEffort: true,
requiredReasoningEffort: false,
supportsPromptCache: true,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
cacheWritesPrice: 100, // 0.0001 * 1_000_000
cacheReadsPrice: 50, // 0.00005 * 1_000_000
description: "Fast coding model",
deprecated: false,
isFree: false,
},
})
})
it("should handle reasoning-required tag", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/reasoning-required-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Reasoning Required Model",
description: "Model that requires reasoning",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["reasoning", "reasoning-required"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/reasoning-required-model"]).toEqual({
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsReasoningEffort: true,
requiredReasoningEffort: true,
supportsPromptCache: false,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
cacheWritesPrice: undefined,
cacheReadsPrice: undefined,
description: "Model that requires reasoning",
deprecated: false,
isFree: false,
defaultTemperature: undefined,
isStealthModel: undefined,
})
})
it("should handle models without required_reasoning_effort field", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/normal-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Normal Model",
description: "Normal model without reasoning",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/normal-model"]).toEqual({
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsReasoningEffort: false,
requiredReasoningEffort: false,
supportsPromptCache: false,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
cacheWritesPrice: undefined,
cacheReadsPrice: undefined,
description: "Normal model without reasoning",
deprecated: false,
isFree: false,
defaultTemperature: undefined,
isStealthModel: undefined,
})
})
it("should work without API key", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/public-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Public Model",
description: "Public model",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl)
expect(mockFetch).toHaveBeenCalledWith(
"https://api.roocode.com/proxy/v1/models",
expect.objectContaining({
headers: expect.not.objectContaining({
Authorization: expect.anything(),
}),
}),
)
expect(models["test/public-model"]).toBeDefined()
})
it("should handle HTTP errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
})
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: HTTP 401: Unauthorized",
)
})
it("should handle timeout", async () => {
const abortError = new Error("AbortError")
abortError.name = "AbortError"
mockFetch.mockRejectedValueOnce(abortError)
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: Request timed out",
)
})
it("should handle invalid response format", async () => {
const invalidResponse = {
invalid: "data",
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => invalidResponse,
})
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: Unexpected response format",
)
})
it("should normalize base URL correctly", async () => {
const mockResponse = {
object: "list",
data: [],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
await getRooModels("https://api.roocode.com/proxy/v1", apiKey)
expect(mockFetch).toHaveBeenCalledWith("https://api.roocode.com/proxy/v1/models", expect.any(Object))
})
it("should handle deprecated models", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/deprecated-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Deprecated Model",
description: "Old model",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
deprecated: true,
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/deprecated-model"].deprecated).toBe(true)
})
it("should detect vision support from tags", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/vision-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Vision Model",
description: "Model with vision",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["vision"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/vision-model"].supportsImages).toBe(true)
})
it("should detect reasoning support from tags", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/reasoning-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Reasoning Model",
description: "Model with reasoning",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["reasoning"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/reasoning-model"].supportsReasoningEffort).toBe(true)
})
it("should handle models with cache pricing", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/cache-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Cache Model",
description: "Model with cache",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
input_cache_read: "0.00005",
input_cache_write: "0.0001",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/cache-model"].supportsPromptCache).toBe(true)
expect(models["test/cache-model"].cacheReadsPrice).toBe(50) // 0.00005 * 1_000_000
expect(models["test/cache-model"].cacheWritesPrice).toBe(100) // 0.0001 * 1_000_000
})
it("should skip models without ID", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Invalid Model",
description: "Model without ID",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(Object.keys(models)).toHaveLength(0)
})
it("should use model name as description fallback", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/no-description",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model Name",
description: "",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/no-description"].description).toBe("Model Name")
})
it("should handle network errors", async () => {
mockFetch.mockRejectedValueOnce(new TypeError("Network error"))
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: No response from server",
)
})
it("should parse default_temperature from API response", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/model-with-temp",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Default Temperature",
description: "Model with custom default temperature",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
default_temperature: 0.6,
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/model-with-temp"].defaultTemperature).toBe(0.6)
})
it("should handle models without default_temperature", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/model-no-temp",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model without Default Temperature",
description: "Model without custom default temperature",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/model-no-temp"].defaultTemperature).toBeUndefined()
})
it("should include models when tool-use tags are present", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/native-tools-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Native Tools Model",
description: "Model with native tool calling default",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use", "default-native-tools"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/native-tools-model"]).toBeDefined()
})
it("handles models when tool tags are absent", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/model-without-tool-tags",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model Without Tool Tags",
description: "Model without any tool-related tags",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [], // No tool-related tags
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/model-without-tool-tags"]).toBeDefined()
})
it("handles models with tool-use tag", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/tool-use-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Tool Use Model",
description: "Model with tool-use tag",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/tool-use-model"]).toBeDefined()
})
it("should detect stealth mode from tags", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/stealth-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Stealth Model",
description: "Model with stealth mode",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["stealth"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/stealth-model"].isStealthModel).toBe(true)
})
it("should not set isStealthModel when stealth tag is absent", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/non-stealth-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Non-Stealth Model",
description: "Model without stealth mode",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/non-stealth-model"].isStealthModel).toBeUndefined()
})
it("should apply API-provided settings to model info", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/model-with-settings",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Settings",
description: "Model with API-provided settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use"],
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
reasoningEffort: "high",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/model-with-settings"].includedTools).toEqual(["apply_patch"])
expect(models["test/model-with-settings"].excludedTools).toEqual(["apply_diff", "write_to_file"])
expect(models["test/model-with-settings"].reasoningEffort).toBe("high")
})
it("should handle arbitrary settings properties dynamically", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/dynamic-settings-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Dynamic Settings Model",
description: "Model with arbitrary settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [],
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
customProperty: "custom-value",
anotherSetting: 42,
nestedConfig: { key: "value" },
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
const model = models["test/dynamic-settings-model"] as any
// Arbitrary settings should be passed through
expect(model.customProperty).toBe("custom-value")
expect(model.anotherSetting).toBe(42)
expect(model.nestedConfig).toEqual({ key: "value" })
})
it("should apply versioned settings when version matches", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/versioned-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Versioned Settings",
description: "Model with versioned settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use"],
pricing: {
input: "0.0001",
output: "0.0002",
},
// Plain settings for backward compatibility with old clients
settings: {
includedTools: ["apply_patch"],
excludedTools: ["write_to_file"],
},
// Versioned settings keyed by version number (low version - always met)
versionedSettings: {
"1.0.0": {
includedTools: ["apply_patch", "search_replace"],
excludedTools: ["apply_diff", "write_to_file"],
},
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
// Versioned settings should be used instead of plain settings
expect(models["test/versioned-model"].includedTools).toEqual(["apply_patch", "search_replace"])
expect(models["test/versioned-model"].excludedTools).toEqual(["apply_diff", "write_to_file"])
})
it("should use plain settings when no versioned settings version matches", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/old-version-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model for Old Version",
description: "Model with versioned settings for newer version",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use"],
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
includedTools: ["apply_patch"],
},
// Versioned settings keyed by very high version - never met
versionedSettings: {
"99.0.0": {
includedTools: ["apply_patch", "search_replace"],
},
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
// Should use plain settings since no versioned settings match current version
expect(models["test/old-version-model"].includedTools).toEqual(["apply_patch"])
})
it("should handle model with only versionedSettings and no plain settings", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/versioned-only-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Only Versioned Settings",
description: "Model with only versioned settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [],
pricing: {
input: "0.0001",
output: "0.0002",
},
// No plain settings, only versionedSettings keyed by version
versionedSettings: {
"1.0.0": {
customFeature: true,
},
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
const model = models["test/versioned-only-model"] as Record<string, unknown>
expect(model.customFeature).toBe(true)
})
it("should select highest matching version from versionedSettings", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/multi-version-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Multiple Versions",
description: "Model with multiple version settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [],
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
feature: "default",
},
// Multiple version keys - should use highest one <= current version
versionedSettings: {
"99.0.0": { feature: "future" },
"3.0.0": { feature: "current" },
"2.0.0": { feature: "old" },
"1.0.0": { feature: "very_old" },
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
const model = models["test/multi-version-model"] as Record<string, unknown>
// Should use 3.0.0 version settings (highest that's <= current plugin version)
expect(model.feature).toBe("current")
})
it("should apply highest versionedSettings for nightly builds (package name)", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/nightly-version-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Nightly Model",
description: "Model with multiple versioned settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
feature: "plain",
},
versionedSettings: {
"3.36.3": { feature: "v3" },
"2.0.0": { feature: "v2" },
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
// Simulate nightly build via package name
const originalName = Package.name
;(Package as { name: string }).name = "roo-code-nightly"
try {
const models = await getRooModels(baseUrl, apiKey)
const model = models["test/nightly-version-model"] as Record<string, unknown>
// Should pick the highest available versionedSettings even though 3.36.3 > 0.0.7465
expect(model.feature).toBe("v3")
} finally {
;(Package as { name: string }).name = originalName
}
})
})