feat: add Cloud.ru Foundation Models provider

- Add CloudRu provider with support for GigaChat and Qwen models
- Implement CloudRuHandler extending BaseOpenAiCompatibleProvider
- Add model definitions for GigaChat (Max, Pro, Plus, base, 2-Max) and Qwen Coder models
- Include comprehensive test coverage for the new provider
- Update provider settings and type definitions

Closes #9320
This commit is contained in:
Roo Code 2025-11-17 19:13:44 +00:00
parent 0e51a1ab9b
commit 5591ae603b
7 changed files with 483 additions and 0 deletions

View file

@ -24,6 +24,7 @@ import {
xaiModels,
internationalZAiModels,
minimaxModels,
cloudRuModels,
} from "./providers/index.js"
/**
@ -139,6 +140,7 @@ export const providerNames = [
"vertex",
"xai",
"zai",
"cloudru",
] as const
export const providerNamesSchema = z.enum(providerNames)
@ -419,6 +421,11 @@ const rooSchema = apiModelIdProviderModelSchema.extend({
// No additional fields needed - uses cloud authentication.
})
const cloudRuSchema = apiModelIdProviderModelSchema.extend({
cloudRuApiKey: z.string().optional(),
cloudRuBaseUrl: z.string().optional(),
})
const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
vercelAiGatewayApiKey: z.string().optional(),
vercelAiGatewayModelId: z.string().optional(),
@ -466,6 +473,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
rooSchema.merge(z.object({ apiProvider: z.literal("roo") })),
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
cloudRuSchema.merge(z.object({ apiProvider: z.literal("cloudru") })),
defaultSchema,
])
@ -508,6 +516,7 @@ export const providerSettingsSchema = z.object({
...qwenCodeSchema.shape,
...rooSchema.shape,
...vercelAiGatewaySchema.shape,
...cloudRuSchema.shape,
...codebaseIndexProviderSchema.shape,
})
@ -594,6 +603,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
"io-intelligence": "ioIntelligenceModelId",
roo: "apiModelId",
"vercel-ai-gateway": "vercelAiGatewayModelId",
cloudru: "apiModelId",
}
/**
@ -715,6 +725,7 @@ export const MODELS_BY_PROVIDER: Record<
},
xai: { id: "xai", label: "xAI (Grok)", models: Object.keys(xaiModels) },
zai: { id: "zai", label: "Zai", models: Object.keys(internationalZAiModels) },
cloudru: { id: "cloudru", label: "Cloud.ru Foundation Models", models: Object.keys(cloudRuModels) },
// Dynamic providers; models pulled from remote APIs.
glama: { id: "glama", label: "Glama", models: [] },

View file

@ -0,0 +1,98 @@
import type { ModelInfo } from "../model.js"
// Cloud.ru Foundation Models (CFM)
// https://cloud.ru/ai/foundation-models
export type CloudRuModelId = keyof typeof cloudRuModels
export const cloudRuDefaultModelId: CloudRuModelId = "GigaChat-Max"
export const cloudRuModels = {
"GigaChat-Max": {
maxTokens: 32768,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.12, // Pricing per 1000 tokens (estimated)
outputPrice: 0.12,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "GigaChat Max - Most capable model for complex tasks and reasoning",
},
"GigaChat-Pro": {
maxTokens: 32768,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.08,
outputPrice: 0.08,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "GigaChat Pro - Balanced model for professional use cases",
},
"GigaChat-Plus": {
maxTokens: 8192,
contextWindow: 32000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.04,
outputPrice: 0.04,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "GigaChat Plus - Efficient model for standard tasks",
},
GigaChat: {
maxTokens: 8192,
contextWindow: 32000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.02,
outputPrice: 0.02,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "GigaChat - Base model for simple tasks",
},
"GigaChat-2-Max": {
maxTokens: 32768,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.15,
outputPrice: 0.15,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "GigaChat 2 Max - Next generation model with enhanced capabilities",
},
"Qwen3-Coder-480B-A35B-Instruct": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.1,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "Qwen 3 Coder - Specialized model for code generation and analysis (480B parameters)",
},
"Qwen3-Coder-32B-Instruct": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.06,
outputPrice: 0.06,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "Qwen 3 Coder - Efficient coding model (32B parameters)",
},
"Qwen3-Coder-7B-Instruct": {
maxTokens: 32768,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.03,
outputPrice: 0.03,
supportsTemperature: true,
defaultTemperature: 0.7,
description: "Qwen 3 Coder - Lightweight coding model (7B parameters)",
},
} as const satisfies Record<string, ModelInfo>

View file

@ -3,6 +3,7 @@ export * from "./bedrock.js"
export * from "./cerebras.js"
export * from "./chutes.js"
export * from "./claude-code.js"
export * from "./cloudru.js"
export * from "./deepseek.js"
export * from "./doubao.js"
export * from "./featherless.js"
@ -61,6 +62,7 @@ import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
import { deepInfraDefaultModelId } from "./deepinfra.js"
import { minimaxDefaultModelId } from "./minimax.js"
import { cloudRuDefaultModelId } from "./cloudru.js"
// Import the ProviderName type from provider-settings to avoid duplication
import type { ProviderName } from "../provider-settings.js"
@ -141,6 +143,8 @@ export function getProviderDefaultModelId(
return qwenCodeDefaultModelId
case "vercel-ai-gateway":
return vercelAiGatewayDefaultModelId
case "cloudru":
return cloudRuDefaultModelId
case "anthropic":
case "gemini-cli":
case "human-relay":

View file

@ -31,6 +31,7 @@ import {
ChutesHandler,
LiteLLMHandler,
ClaudeCodeHandler,
CloudRuHandler,
QwenCodeHandler,
SambaNovaHandler,
IOIntelligenceHandler,
@ -118,6 +119,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new AnthropicHandler(options)
case "claude-code":
return new ClaudeCodeHandler(options)
case "cloudru":
return new CloudRuHandler(options)
case "glama":
return new GlamaHandler(options)
case "openrouter":

View file

@ -0,0 +1,332 @@
// npx vitest run api/providers/__tests__/cloudru.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { CloudRuHandler } from "../cloudru"
// Create mock functions
const mockCreate = vi.fn()
// Mock OpenAI module
vi.mock("openai", () => ({
default: vi.fn(() => ({
chat: {
completions: {
create: mockCreate,
},
},
})),
}))
describe("CloudRuHandler", () => {
let handler: CloudRuHandler
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("Constructor", () => {
it("should create handler with cloudRuApiKey", () => {
expect(() => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-cloudru-api-key",
})
}).not.toThrow()
})
it("should create handler with generic apiKey as fallback", () => {
expect(() => {
handler = new CloudRuHandler({
apiKey: "test-api-key",
})
}).not.toThrow()
})
it("should throw error when no API key is provided", () => {
expect(() => {
handler = new CloudRuHandler({})
}).toThrow("Cloud.ru API key is required")
})
it("should use custom base URL when provided", () => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
cloudRuBaseUrl: "https://custom.cloudru.api/v1",
})
// The base URL is passed to the parent class
expect(handler).toBeDefined()
})
it("should use default base URL when not provided", () => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
// The default URL is used
expect(handler).toBeDefined()
})
})
describe("Model selection", () => {
it("should use default model when apiModelId is not provided", () => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const model = handler.getModel()
expect(model.id).toBe("GigaChat-Max")
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(32768)
})
it("should use specified model when apiModelId is provided", () => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
apiModelId: "GigaChat-Pro",
})
const model = handler.getModel()
expect(model.id).toBe("GigaChat-Pro")
expect(model.info).toBeDefined()
})
it("should fallback to default model for invalid apiModelId", () => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
apiModelId: "invalid-model",
})
const model = handler.getModel()
expect(model.id).toBe("GigaChat-Max")
})
it("should support Qwen models", () => {
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
apiModelId: "Qwen3-Coder-480B-A35B-Instruct",
})
const model = handler.getModel()
expect(model.id).toBe("Qwen3-Coder-480B-A35B-Instruct")
expect(model.info.description).toContain("Qwen 3 Coder")
})
})
describe("Message creation", () => {
it("should create message stream with correct parameters", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const messageGenerator = handler.createMessage(systemPrompt, messages)
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "GigaChat-Max",
temperature: 0.7,
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
stream: true,
stream_options: { include_usage: true },
}),
undefined,
)
})
it("should handle streaming responses", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: { content: "Hello from " } }],
},
})
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: { content: "Cloud.ru!" } }],
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const stream = handler.createMessage("system", [])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toEqual([
{ type: "text", text: "Hello from " },
{ type: "text", text: "Cloud.ru!" },
])
})
it("should handle usage data in stream", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
next: vi
.fn()
.mockResolvedValueOnce({
done: false,
value: {
choices: [{ delta: { content: "Response" } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
},
})
.mockResolvedValueOnce({ done: true }),
}),
}
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const stream = handler.createMessage("system", [])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toContainEqual({
type: "usage",
inputTokens: 10,
outputTokens: 5,
})
})
})
describe("Prompt completion", () => {
it("should complete prompt successfully", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
content: "Completed response from Cloud.ru",
},
},
],
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Completed response from Cloud.ru")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "GigaChat-Max",
messages: [{ role: "user", content: "Test prompt" }],
}),
)
})
it("should handle empty response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
content: null,
},
},
],
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
})
describe("Temperature configuration", () => {
it("should use custom temperature when provided", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
modelTemperature: 0.3,
})
const messageGenerator = handler.createMessage("system", [])
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.3,
}),
undefined,
)
})
it("should use default temperature when not provided", async () => {
mockCreate.mockImplementationOnce(() => {
return {
[Symbol.asyncIterator]: () => ({
async next() {
return { done: true }
},
}),
}
})
handler = new CloudRuHandler({
cloudRuApiKey: "test-key",
})
const messageGenerator = handler.createMessage("system", [])
await messageGenerator.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.7,
}),
undefined,
)
})
})
})

View file

@ -0,0 +1,34 @@
import type { CloudRuModelId, ModelInfo } from "@roo-code/types"
import { cloudRuModels, cloudRuDefaultModelId } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
/**
* Cloud.ru Foundation Models (CFM) provider handler
* Supports GigaChat and Qwen models through OpenAI-compatible API
*/
export class CloudRuHandler extends BaseOpenAiCompatibleProvider<CloudRuModelId> {
constructor(options: ApiHandlerOptions) {
// Use custom base URL if provided, otherwise use default Cloud.ru API endpoint
const baseURL = options.cloudRuBaseUrl || "https://api.cloud.ru/v1"
// Use cloudRuApiKey if provided, otherwise fall back to generic apiKey
const apiKey = options.cloudRuApiKey || options.apiKey
if (!apiKey) {
throw new Error("Cloud.ru API key is required")
}
super({
providerName: "Cloud.ru",
baseURL,
defaultProviderModelId: cloudRuDefaultModelId,
providerModels: cloudRuModels as Record<CloudRuModelId, ModelInfo>,
defaultTemperature: 0.7,
...options,
apiKey,
})
}
}

View file

@ -4,6 +4,7 @@ export { AwsBedrockHandler } from "./bedrock"
export { CerebrasHandler } from "./cerebras"
export { ChutesHandler } from "./chutes"
export { ClaudeCodeHandler } from "./claude-code"
export { CloudRuHandler } from "./cloudru"
export { DeepSeekHandler } from "./deepseek"
export { DoubaoHandler } from "./doubao"
export { MoonshotHandler } from "./moonshot"