mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
0e51a1ab9b
commit
5591ae603b
7 changed files with 483 additions and 0 deletions
|
|
@ -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: [] },
|
||||
|
|
|
|||
98
packages/types/src/providers/cloudru.ts
Normal file
98
packages/types/src/providers/cloudru.ts
Normal 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>
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
332
src/api/providers/__tests__/cloudru.spec.ts
Normal file
332
src/api/providers/__tests__/cloudru.spec.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
34
src/api/providers/cloudru.ts
Normal file
34
src/api/providers/cloudru.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue