From 5591ae603b2b7f061c26b9ab0b8486cd7ec0a742 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 17 Nov 2025 19:13:44 +0000 Subject: [PATCH] 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 --- packages/types/src/provider-settings.ts | 11 + packages/types/src/providers/cloudru.ts | 98 ++++++ packages/types/src/providers/index.ts | 4 + src/api/index.ts | 3 + src/api/providers/__tests__/cloudru.spec.ts | 332 ++++++++++++++++++++ src/api/providers/cloudru.ts | 34 ++ src/api/providers/index.ts | 1 + 7 files changed, 483 insertions(+) create mode 100644 packages/types/src/providers/cloudru.ts create mode 100644 src/api/providers/__tests__/cloudru.spec.ts create mode 100644 src/api/providers/cloudru.ts diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 7a84e6d2de..a422b6d598 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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 = { "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: [] }, diff --git a/packages/types/src/providers/cloudru.ts b/packages/types/src/providers/cloudru.ts new file mode 100644 index 0000000000..806fd44771 --- /dev/null +++ b/packages/types/src/providers/cloudru.ts @@ -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 diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 3db2c7fb10..659d1da79d 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -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": diff --git a/src/api/index.ts b/src/api/index.ts index 05c7493078..db07ab5f48 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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": diff --git a/src/api/providers/__tests__/cloudru.spec.ts b/src/api/providers/__tests__/cloudru.spec.ts new file mode 100644 index 0000000000..da138e48a1 --- /dev/null +++ b/src/api/providers/__tests__/cloudru.spec.ts @@ -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, + ) + }) + }) +}) diff --git a/src/api/providers/cloudru.ts b/src/api/providers/cloudru.ts new file mode 100644 index 0000000000..eccee44833 --- /dev/null +++ b/src/api/providers/cloudru.ts @@ -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 { + 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, + defaultTemperature: 0.7, + ...options, + apiKey, + }) + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 533023d037..4875c5a496 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -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"