diff --git a/core/llm/autodetect.ts b/core/llm/autodetect.ts index c8511554b8b..4258c9a8e88 100644 --- a/core/llm/autodetect.ts +++ b/core/llm/autodetect.ts @@ -69,6 +69,7 @@ const PROVIDER_HANDLES_TEMPLATING: string[] = [ "xAI", "minimax", "groq", + "rodiumai", "gemini", "docker", "nous", @@ -123,6 +124,7 @@ const PROVIDER_SUPPORTS_IMAGES: string[] = [ "sagemaker", "openrouter", "clawrouter", + "rodiumai", "venice", "sambanova", "vertexai", diff --git a/core/llm/fetchModels.ts b/core/llm/fetchModels.ts index 88fe1946f95..0d90c286bd7 100644 --- a/core/llm/fetchModels.ts +++ b/core/llm/fetchModels.ts @@ -1,3 +1,4 @@ +import { fetchRodiumAiModels } from "./fetchRodiumAiModels.js"; import { LLMClasses, llmFromProviderAndOptions } from "./llms/index.js"; export interface FetchedModel { @@ -248,6 +249,8 @@ export async function fetchModels( return fetchOllamaModels(); case "openrouter": return fetchOpenRouterModels(); + case "rodiumai": + return fetchRodiumAiModels(apiKey, apiBase); case "anthropic": return fetchAnthropicModels(apiKey); case "gemini": diff --git a/core/llm/fetchRodiumAiModels.test.ts b/core/llm/fetchRodiumAiModels.test.ts new file mode 100644 index 00000000000..d43f828df20 --- /dev/null +++ b/core/llm/fetchRodiumAiModels.test.ts @@ -0,0 +1,99 @@ +import { + fetchRodiumAiModels, + getRodiumAiModelIcon, +} from "./fetchRodiumAiModels.js"; + +describe("fetchRodiumAiModels", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + test("getRodiumAiModelIcon maps provider slugs and model ids", () => { + expect(getRodiumAiModelIcon("anthropic/claude-fable-5", "anthropic")).toBe( + "anthropic.png", + ); + expect(getRodiumAiModelIcon("openai/gpt-5.4", "openai")).toBe("openai.png"); + expect(getRodiumAiModelIcon("custom/unknown-model")).toBe("rodium.svg"); + }); + + test("maps RodiumAi model extensions into FetchedModel", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + object: "list", + data: [ + { + id: "anthropic/claude-fable-5", + rodiumai_display_name: "Claude Fable 5", + rodiumai_description: "Anthropic creative model", + rodiumai_provider: { slug: "anthropic", name: "Anthropic" }, + rodiumai_capabilities: { + context_window: 200000, + max_output_tokens: 8192, + supports_tools: true, + }, + }, + { + id: "openai/gpt-5.4", + rodiumai_display_name: "GPT-5.4", + rodiumai_provider: { slug: "openai", name: "OpenAI" }, + rodiumai_capabilities: { + context_window: 1050000, + max_output_tokens: 128000, + supports_tools: true, + }, + }, + ], + }), + }) as typeof fetch; + + const models = await fetchRodiumAiModels("rd_sk_prod_test"); + + expect(global.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://api.rodiumai.io/v1/models", + }), + expect.objectContaining({ + headers: { Authorization: "Bearer rd_sk_prod_test" }, + }), + ); + + expect(models).toHaveLength(2); + expect(models[0]).toEqual({ + name: "Claude Fable 5", + modelId: "anthropic/claude-fable-5", + description: "Anthropic creative model", + icon: "anthropic.png", + contextLength: 200000, + maxTokens: 8192, + supportsTools: true, + }); + expect(models[1]).toMatchObject({ + name: "GPT-5.4", + modelId: "openai/gpt-5.4", + icon: "openai.png", + contextLength: 1050000, + maxTokens: 128000, + supportsTools: true, + }); + }); + + test("returns an empty list when the RodiumAi API fails", async () => { + const consoleError = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 503, + }) as typeof fetch; + + const models = await fetchRodiumAiModels("rd_sk_prod_test"); + + expect(models).toEqual([]); + expect(consoleError).toHaveBeenCalled(); + }); +}); diff --git a/core/llm/fetchRodiumAiModels.ts b/core/llm/fetchRodiumAiModels.ts new file mode 100644 index 00000000000..519dc7582ab --- /dev/null +++ b/core/llm/fetchRodiumAiModels.ts @@ -0,0 +1,96 @@ +const RODIUMAI_DEFAULT_API_BASE = "https://api.rodiumai.io/v1/"; + +interface RodiumAiFetchedModel { + name: string; + modelId?: string; + description?: string; + icon?: string; + contextLength?: number; + maxTokens?: number; + supportsTools?: boolean; +} + +const RODIUMAI_PROVIDER_ICON_MAP: Record = { + openai: "openai.png", + anthropic: "anthropic.png", + google: "gemini.png", + gemini: "gemini.png", + deepseek: "deepseek.png", + mistral: "mistral.png", + meta: "meta.png", + moonshot: "moonshot.png", + xai: "xAI.png", + cohere: "cohere.png", +}; + +export function getRodiumAiModelIcon( + modelId: string, + providerSlug?: string, +): string { + if (providerSlug && RODIUMAI_PROVIDER_ICON_MAP[providerSlug]) { + return RODIUMAI_PROVIDER_ICON_MAP[providerSlug]; + } + + const lower = modelId.toLowerCase(); + if (lower.includes("claude")) { + return "anthropic.png"; + } + if (lower.includes("gpt") || lower.startsWith("openai/")) { + return "openai.png"; + } + if (lower.includes("gemini") || lower.includes("gemma")) { + return "gemini.png"; + } + if (lower.includes("deepseek")) { + return "deepseek.png"; + } + if (lower.includes("mistral")) { + return "mistral.png"; + } + + return "rodium.svg"; +} + +export async function fetchRodiumAiModels( + apiKey?: string, + apiBase?: string, +): Promise { + try { + const base = apiBase || RODIUMAI_DEFAULT_API_BASE; + const url = new URL("models", base); + const headers: Record = {}; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`Failed to fetch RodiumAi models: ${response.status}`); + } + + const data = await response.json(); + if (!data.data || !Array.isArray(data.data)) { + return []; + } + + return data.data + .filter((m: any) => m.id) + .map((m: any) => { + const providerSlug: string | undefined = m.rodiumai_provider?.slug; + const capabilities = m.rodiumai_capabilities ?? {}; + + return { + name: m.rodiumai_display_name ?? m.id, + modelId: m.id, + description: m.rodiumai_description, + icon: getRodiumAiModelIcon(m.id, providerSlug), + contextLength: capabilities.context_window, + maxTokens: capabilities.max_output_tokens, + supportsTools: capabilities.supports_tools, + }; + }); + } catch (error) { + console.error("Error fetching RodiumAi models:", error); + return []; + } +} diff --git a/core/llm/llms/RodiumAi.ts b/core/llm/llms/RodiumAi.ts new file mode 100644 index 00000000000..b2bbeb1e4ed --- /dev/null +++ b/core/llm/llms/RodiumAi.ts @@ -0,0 +1,177 @@ +import { ChatCompletionCreateParams } from "openai/resources/index"; + +import { LLMOptions } from "../../index.js"; +import { osModelsEditPrompt } from "../templates/edit.js"; + +import OpenAI from "./OpenAI.js"; + +const CONTINUE_VERSION = process.env.npm_package_version || "unknown"; + +class RodiumAi extends OpenAI { + static providerName = "rodiumai"; + protected supportsReasoningField = true; + protected supportsReasoningDetailsField = true; + static defaultOptions: Partial = { + apiBase: "https://api.rodiumai.io/v1/", + promptTemplates: { + edit: osModelsEditPrompt, + }, + useLegacyCompletionsEndpoint: false, + }; + + protected _getHeaders() { + return { + ...super._getHeaders(), + "User-Agent": `Continue/${CONTINUE_VERSION}`, + "X-Continue-Provider": "rodiumai", + }; + } + + private isAnthropicModel(model?: string): boolean { + if (!model) return false; + const modelLower = model.toLowerCase(); + return modelLower.includes("claude") || modelLower.startsWith("anthropic/"); + } + + private isGeminiModel(model?: string): boolean { + if (!model) return false; + const modelLower = model.toLowerCase(); + return ( + modelLower.startsWith("google/") || + modelLower.startsWith("gemini/") || + modelLower.includes("gemini") + ); + } + + private addGeminiThoughtSignatures( + body: ChatCompletionCreateParams, + ): ChatCompletionCreateParams { + body.messages = body.messages.map((message: any) => { + if (message.role === "assistant" && message.tool_calls?.length) { + return { + ...message, + tool_calls: message.tool_calls.map((toolCall: any, index: number) => { + if (index !== 0) return toolCall; + if (toolCall.extra_content?.google?.thought_signature) { + return toolCall; + } + return { + ...toolCall, + extra_content: { + ...toolCall.extra_content, + google: { + ...toolCall.extra_content?.google, + thought_signature: "skip_thought_signature_validator", + }, + }, + }; + }), + }; + } + return message; + }); + return body; + } + + private addCacheControlToContent(content: any, addCaching: boolean): any { + if (!addCaching) return content; + + if (typeof content === "string") { + return [ + { + type: "text", + text: content, + cache_control: { type: "ephemeral" }, + }, + ]; + } + + if (Array.isArray(content)) { + return content.map((part, idx) => { + if (part.type === "text" && idx === content.length - 1) { + return { + ...part, + cache_control: { type: "ephemeral" }, + }; + } + return part; + }); + } + + return content; + } + + protected modifyChatBody( + body: ChatCompletionCreateParams, + ): ChatCompletionCreateParams { + body = super.modifyChatBody(body); + + if (this.isGeminiModel(body.model)) { + body = this.addGeminiThoughtSignatures(body); + } + + if ( + !this.isAnthropicModel(body.model) || + (!this.cacheBehavior && !this.completionOptions.promptCaching) + ) { + return body; + } + + const shouldCacheConversation = + this.cacheBehavior?.cacheConversation || + this.completionOptions.promptCaching; + const shouldCacheSystemMessage = + this.cacheBehavior?.cacheSystemMessage || + this.completionOptions.promptCaching; + + if (!shouldCacheConversation && !shouldCacheSystemMessage) { + return body; + } + + const filteredMessages = body.messages.filter( + (m: any) => m.role !== "system" && !!m.content, + ); + + const lastTwoUserMsgIndices = filteredMessages + .map((msg: any, index: number) => (msg.role === "user" ? index : -1)) + .filter((index: number) => index !== -1) + .slice(-2); + + let filteredIndex = 0; + const filteredToOriginalIndexMap: number[] = []; + body.messages.forEach((msg: any, originalIndex: number) => { + if (msg.role !== "system" && !!msg.content) { + filteredToOriginalIndexMap[filteredIndex] = originalIndex; + filteredIndex++; + } + }); + + body.messages = body.messages.map((message: any, idx) => { + if (message.role === "system" && shouldCacheSystemMessage) { + return { + ...message, + content: this.addCacheControlToContent(message.content, true), + }; + } + + const filteredIdx = filteredToOriginalIndexMap.indexOf(idx); + if ( + message.role === "user" && + shouldCacheConversation && + filteredIdx !== -1 && + lastTwoUserMsgIndices.includes(filteredIdx) + ) { + return { + ...message, + content: this.addCacheControlToContent(message.content, true), + }; + } + + return message; + }); + + return body; + } +} + +export default RodiumAi; diff --git a/core/llm/llms/RodiumAi.vitest.ts b/core/llm/llms/RodiumAi.vitest.ts new file mode 100644 index 00000000000..32f219945fe --- /dev/null +++ b/core/llm/llms/RodiumAi.vitest.ts @@ -0,0 +1,189 @@ +import { ChatCompletionCreateParams } from "openai/resources/index"; +import { describe, expect, it } from "vitest"; + +import RodiumAi from "./RodiumAi"; + +describe("RodiumAi Anthropic Caching", () => { + it("should detect Anthropic models with RodiumAi slugs", () => { + const rodiumAi = new RodiumAi({ + model: "anthropic/claude-fable-5", + apiKey: "test-key", + }); + + const body: ChatCompletionCreateParams = { + model: "anthropic/claude-fable-5", + messages: [], + }; + + expect(() => rodiumAi["modifyChatBody"](body)).not.toThrow(); + }); + + it("should add cache_control to user messages when caching is enabled", () => { + const rodiumAi = new RodiumAi({ + model: "anthropic/claude-sonnet-4-6", + apiKey: "test-key", + cacheBehavior: { + cacheConversation: true, + cacheSystemMessage: false, + }, + }); + + const body: ChatCompletionCreateParams = { + model: "anthropic/claude-sonnet-4-6", + messages: [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Response" }, + { role: "user", content: "Second message" }, + { role: "assistant", content: "Another response" }, + { role: "user", content: "Third message" }, + ], + }; + + const modifiedBody = rodiumAi["modifyChatBody"](body); + const userMessages = modifiedBody.messages.filter( + (msg: any) => msg.role === "user", + ); + + expect(userMessages[0].content).toBe("First message"); + expect(userMessages[1].content).toEqual([ + { + type: "text", + text: "Second message", + cache_control: { type: "ephemeral" }, + }, + ]); + expect(userMessages[2].content).toEqual([ + { + type: "text", + text: "Third message", + cache_control: { type: "ephemeral" }, + }, + ]); + }); + + it("should correctly handle cache_control with system messages present", () => { + const rodiumAi = new RodiumAi({ + model: "anthropic/claude-fable-5", + apiKey: "test-key", + cacheBehavior: { + cacheConversation: true, + cacheSystemMessage: true, + }, + }); + + const body: ChatCompletionCreateParams = { + model: "anthropic/claude-fable-5", + messages: [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "First user message" }, + { role: "assistant", content: "First assistant response" }, + { role: "user", content: "Second user message" }, + { role: "assistant", content: "Second assistant response" }, + { role: "user", content: "Third user message" }, + ], + }; + + const modifiedBody = rodiumAi["modifyChatBody"](body); + + expect(modifiedBody.messages[0]).toEqual({ + role: "system", + content: [ + { + type: "text", + text: "You are a helpful assistant", + cache_control: { type: "ephemeral" }, + }, + ], + }); + + const userMessages = modifiedBody.messages.filter( + (msg: any) => msg.role === "user", + ); + + expect(userMessages[0].content).toBe("First user message"); + expect(userMessages[1].content).toEqual([ + { + type: "text", + text: "Second user message", + cache_control: { type: "ephemeral" }, + }, + ]); + expect(userMessages[2].content).toEqual([ + { + type: "text", + text: "Third user message", + cache_control: { type: "ephemeral" }, + }, + ]); + }); + + it("should not modify messages for non-Anthropic RodiumAi models", () => { + const rodiumAi = new RodiumAi({ + model: "openai/gpt-5.4", + apiKey: "test-key", + cacheBehavior: { + cacheConversation: true, + cacheSystemMessage: true, + }, + }); + + const body: ChatCompletionCreateParams = { + model: "openai/gpt-5.4", + messages: [ + { role: "system", content: "System message" }, + { role: "user", content: "User message" }, + ], + }; + + const modifiedBody = rodiumAi["modifyChatBody"](body); + + expect(modifiedBody.messages).toEqual(body.messages); + }); +}); + +describe("RodiumAi Gemini tool calls", () => { + it("should add thought_signature fallback for google/ models", () => { + const rodiumAi = new RodiumAi({ + model: "google/gemini-2.5-pro", + apiKey: "test-key", + }); + + const body: ChatCompletionCreateParams = { + model: "google/gemini-2.5-pro", + messages: [ + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + ], + }; + + const modifiedBody = rodiumAi["modifyChatBody"](body); + const assistantMessage = modifiedBody.messages[0] as any; + + expect(assistantMessage.tool_calls[0].extra_content.google).toEqual({ + thought_signature: "skip_thought_signature_validator", + }); + }); +}); + +describe("RodiumAi headers", () => { + it("should include Continue provider headers", () => { + const rodiumAi = new RodiumAi({ + model: "anthropic/claude-fable-5", + apiKey: "test-key", + }); + + const headers = rodiumAi["_getHeaders"](); + + expect(headers["X-Continue-Provider"]).toBe("rodiumai"); + expect(headers["User-Agent"]).toMatch(/^Continue\//); + }); +}); diff --git a/core/llm/llms/index.ts b/core/llm/llms/index.ts index 4978f0617f2..fe216d3866d 100644 --- a/core/llm/llms/index.ts +++ b/core/llm/llms/index.ts @@ -54,6 +54,7 @@ import ClawRouter from "./ClawRouter"; import OVHcloud from "./OVHcloud"; import { Relace } from "./Relace"; import Replicate from "./Replicate"; +import RodiumAi from "./RodiumAi"; import SageMaker from "./SageMaker"; import SambaNova from "./SambaNova"; import Scaleway from "./Scaleway"; @@ -127,6 +128,7 @@ export const LLMClasses = [ Tensorix, Scaleway, Relace, + RodiumAi, Inception, Voyage, LlamaStack, diff --git a/docs/customize/model-providers/more/rodiumai.mdx b/docs/customize/model-providers/more/rodiumai.mdx new file mode 100644 index 00000000000..5140fe296bc --- /dev/null +++ b/docs/customize/model-providers/more/rodiumai.mdx @@ -0,0 +1,59 @@ +--- +title: "How to Configure RodiumAi with Continue" +sidebarTitle: "RodiumAi" +--- + + + Get your API key from the [RodiumAi Dashboard](https://rodiumai.io/dashboard/api-keys). Keys start with `rd_sk_prod_`. + + +RodiumAi is an OpenAI-compatible API gateway that routes requests to models from OpenAI, Anthropic, Google, and other providers. Models use catalogue slugs in the form `provider/model` (for example, `anthropic/claude-fable-5` or `openai/gpt-5.4`). + +## Configuration + + + + ```yaml title="config.yaml" + name: My Config + version: 0.0.1 + schema: v1 + + models: + - name: Claude Fable 5 + provider: rodiumai + model: anthropic/claude-fable-5 + apiKey: + ``` + + + ```json title="config.json" + { + "models": [ + { + "title": "Claude Fable 5", + "provider": "rodiumai", + "model": "anthropic/claude-fable-5", + "apiKey": "" + } + ] + } + ``` + + + +## Available Models + +Use `GET https://api.rodiumai.io/v1/models` to list routable models, or browse the catalogue at [rodiumai.io/models](https://rodiumai.io/models). + +| Example slug | Description | +| :----------- | :---------- | +| `anthropic/claude-fable-5` | Claude Fable 5 via RodiumAi | +| `anthropic/claude-sonnet-4-6` | Claude Sonnet 4.6 via RodiumAi | +| `openai/gpt-5.4` | GPT-5.4 via RodiumAi | +| `openai/gpt-5.4-mini` | GPT-5.4 Mini via RodiumAi | + +## Get your API key + +1. Sign up at [rodiumai.io](https://rodiumai.io) +2. Open the [API Keys page](https://rodiumai.io/dashboard/api-keys) +3. Create a key and paste it into your Continue config diff --git a/docs/customize/model-providers/overview.mdx b/docs/customize/model-providers/overview.mdx index 7ba030dcb4d..9296855a346 100644 --- a/docs/customize/model-providers/overview.mdx +++ b/docs/customize/model-providers/overview.mdx @@ -34,6 +34,7 @@ Beyond the top-level providers, Continue supports many other options: | [Together AI](/customize/model-providers/more/together) | Platform for running a variety of open models | | [DeepInfra](/customize/model-providers/more/deepinfra) | Hosting for various open source models | | [OpenRouter](/customize/model-providers/top-level/openrouter) | Gateway to multiple model providers | +| [RodiumAi](/customize/model-providers/more/rodiumai) | OpenAI-compatible gateway to leading models with RODI billing | | [ClawRouter](/customize/model-providers/more/clawrouter) | Open-source LLM router with automatic cost-optimized model selection | | [Tetrate Agent Router Service](/customize/model-providers/top-level/tetrate_agent_router_service) | Gateway with intelligent routing across multiple model providers | | [Cohere](/customize/model-providers/more/cohere) | Models specialized for semantic search and text generation | diff --git a/extensions/vscode/config_schema.json b/extensions/vscode/config_schema.json index fb3f4c61362..68717da27e4 100644 --- a/extensions/vscode/config_schema.json +++ b/extensions/vscode/config_schema.json @@ -226,6 +226,7 @@ "vertexai", "xAI", "kindo", + "rodiumai", "moonshot", "siliconflow", "tensorix", @@ -279,6 +280,7 @@ "### Vertex AI\nVertex AI provides access to Google's foundation models and ML tools. To get started, enable the [Vertex AI API](https://console.cloud.google.com/marketplace/product/google/aiplatform.googleapis.com) and set up [Google Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc).", "### xAI offers a world class developer tool set to build scalable applications powered by Grok. To get started, obtain an API key from [the x Console](https://console.x.ai/), and see the [docs](https://docs.x.ai/docs/)", "### Secure AI management software that helps enterprises adopt and manage AI across their workforce. To get started, obtain an API key from [the Kindo console](https://app.kindo.ai/settings/api), and see the [website](https://app.kindo.ai//)", + "### RodiumAi\nRodiumAi provides unified access to leading AI models through an OpenAI-compatible API. Get your API key at [rodiumai.io](https://rodiumai.io).", "### Moonshot\nTo get started with Moonshot AI, obtain your API key from [Moonshot AI](https://platform.moonshot.cn/). Moonshot AI provides high-quality large language models with competitive pricing.\n> [Reference](https://platform.moonshot.cn/docs/api)", "### SiliconFlow\nTo get started with SiliconFlow, obtain your API key from [SiliconCloud](https://cloud.siliconflow.cn/account/ak). SiliconCloud provides cost-effective GenAI services based on excellent open source basic models.\n> [Models](https://siliconflow.cn/zh-cn/models)", "### Tensorix\nTensorix is an OpenAI-compatible API gateway with access to DeepSeek, Llama, Qwen, GLM, and other models. Pay-as-you-go with no subscription required.\nTo get started, create an account and get an API key at [app.tensorix.ai](https://app.tensorix.ai).\n> [Models](https://tensorix.ai/models)", @@ -534,6 +536,7 @@ "nebius", "xAI", "kindo", + "rodiumai", "scaleway", "ovhcloud", "venice" diff --git a/gui/public/logos/rodium.svg b/gui/public/logos/rodium.svg new file mode 100644 index 00000000000..376b7cef724 --- /dev/null +++ b/gui/public/logos/rodium.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/gui/src/components/CliInstallBanner.tsx b/gui/src/components/CliInstallBanner.tsx index 36662309afb..226d578b4c9 100644 --- a/gui/src/components/CliInstallBanner.tsx +++ b/gui/src/components/CliInstallBanner.tsx @@ -48,12 +48,16 @@ export function CliInstallBanner({ }; useEffect(() => { + let isMounted = true; + // Check if user has permanently dismissed the banner if (permanentDismissal) { const hasDismissed = getLocalStorage("hasDismissedCliInstallBanner"); if (hasDismissed) { setDismissed(true); - return; + return () => { + isMounted = false; + }; } } @@ -65,6 +69,10 @@ export function CliInstallBanner({ const [stdout, stderr] = await ideMessenger.ide.subprocess(command); + if (!isMounted) { + return; + } + // If stdout has content (path to cn), it's installed // If empty or stderr has "not found", it's not installed const isInstalled = @@ -72,11 +80,17 @@ export function CliInstallBanner({ setCliInstalled(isInstalled); } catch (error) { // If subprocess throws an error, assume CLI is not installed - setCliInstalled(false); + if (isMounted) { + setCliInstalled(false); + } } }; void checkCliInstallation(); + + return () => { + isMounted = false; + }; }, [ideMessenger, permanentDismissal]); const handleDismiss = () => { diff --git a/gui/src/forms/AddModelForm.tsx b/gui/src/forms/AddModelForm.tsx index d64b5cf26f5..fe2b6997d31 100644 --- a/gui/src/forms/AddModelForm.tsx +++ b/gui/src/forms/AddModelForm.tsx @@ -127,7 +127,7 @@ export function AddModelForm({ onDone }: AddModelFormProps) { if (!selectedProvider.tags?.includes(ModelProviderTags.RequiresApiKey)) { formMethods.setValue("apiKey", ""); } - }, [selectedProvider]); + }, [selectedProvider, formMethods]); const requiresSkPrefix = selectedProvider.provider === "openai" || @@ -142,6 +142,24 @@ export function AddModelForm({ onDone }: AddModelFormProps) { ? "API key usually starts with sk-" : undefined; + useEffect(() => { + if ( + !apiKeyValue || + apiKeyValue.length === 0 || + selectedProvider.provider === "ollama" || + selectedProvider.provider === "openrouter" || + selectedProvider.provider === "rodiumai" + ) { + return; + } + + const timeout = setTimeout(() => { + void handleFetchModels(); + }, 500); + + return () => clearTimeout(timeout); + }, [apiKeyValue, selectedProvider.provider, handleFetchModels]); + function onSubmit() { const apiKey = formMethods.watch("apiKey"); const hasValidApiKey = apiKey !== undefined && apiKey !== ""; diff --git a/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts b/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts index 6e0dce45a16..ab994c31033 100644 --- a/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts +++ b/gui/src/pages/AddNewModel/configs/fetchProviderModels.ts @@ -65,7 +65,7 @@ function toGenericPackage(model: FetchedModel, provider: string): ModelPackage { const id = model.modelId ?? model.name; return { title: model.name, - description: model.name, + description: model.description ?? model.name, params: { title: model.name, model: id, @@ -73,6 +73,15 @@ function toGenericPackage(model: FetchedModel, provider: string): ModelPackage { }, isOpenSource: false, providerOptions: [provider], + icon: model.icon, + }; +} + +function toRodiumAiPackage(model: FetchedModel): ModelPackage { + return { + ...toGenericPackage(model, "rodiumai"), + tags: [ModelProviderTags.RequiresApiKey], + icon: model.icon ?? "rodium.svg", }; } @@ -100,6 +109,9 @@ export async function fetchProviderModels( apiBase?: string, ): Promise { const models = await fetchModels(ideMessenger, provider, apiKey, apiBase); + if (provider === "rodiumai") { + return models.map(toRodiumAiPackage); + } return models.map((m) => toGenericPackage(m, provider)); } @@ -141,4 +153,14 @@ export async function initializeDynamicModels(ideMessenger: IIdeMessenger) { } catch (error) { console.error("Failed to initialize OpenRouter models:", error); } + + try { + const fetched = await fetchModels(ideMessenger, "rodiumai"); + const packages = fetched.map(toRodiumAiPackage); + if (packages.length > 0 && providers.rodiumai) { + providers.rodiumai.packages = packages; + } + } catch (error) { + console.error("Failed to initialize RodiumAi models:", error); + } } diff --git a/gui/src/pages/AddNewModel/configs/providers.ts b/gui/src/pages/AddNewModel/configs/providers.ts index 9e2aba08c5c..87071d43616 100644 --- a/gui/src/pages/AddNewModel/configs/providers.ts +++ b/gui/src/pages/AddNewModel/configs/providers.ts @@ -895,6 +895,35 @@ Select the \`GPT-4o\` model below to complete your provider configuration, but n ], apiKeyUrl: "https://replicate.com/account/api-tokens", }, + rodiumai: { + title: "RodiumAi", + provider: "rodiumai", + icon: "rodium.svg", + refPage: "rodiumai", + description: + "OpenAI-compatible gateway to GPT, Claude, Gemini, and more with RODI billing.", + longDescription: `RodiumAi provides unified access to leading AI models through a single OpenAI-compatible API. To get started:\n1. Sign up at [rodiumai.io](https://rodiumai.io)\n2. Create an API key in your [dashboard](https://rodiumai.io/dashboard/api-keys)\n3. Select a model from the catalogue`, + tags: [ModelProviderTags.RequiresApiKey], + apiKeyUrl: "https://rodiumai.io/dashboard/api-keys", + collectInputFor: [ + { + inputType: "text", + key: "apiKey", + label: "API Key", + placeholder: "Enter your RodiumAi API key (rd_sk_prod_...)", + required: true, + }, + ...completionParamsInputsConfigs, + ], + packages: [ + { + title: "Loading models...", + description: "Fetching available models from RodiumAi", + params: { model: "placeholder" }, + isOpenSource: false, + }, + ], + }, "llama.cpp": { title: "llama.cpp", provider: "llama.cpp", diff --git a/packages/openai-adapters/src/apis/RodiumAi.ts b/packages/openai-adapters/src/apis/RodiumAi.ts new file mode 100644 index 00000000000..f96ef0ee373 --- /dev/null +++ b/packages/openai-adapters/src/apis/RodiumAi.ts @@ -0,0 +1,39 @@ +import { ChatCompletionCreateParams } from "openai/resources/index"; + +import { OpenAIConfig } from "../types.js"; +import { OpenAIApi } from "./OpenAI.js"; +import { applyAnthropicCachingToOpenRouterBody } from "./OpenRouterCaching.js"; + +export class RodiumAiApi extends OpenAIApi { + constructor(config: OpenAIConfig) { + super({ + ...config, + apiBase: config.apiBase ?? "https://api.rodiumai.io/v1/", + }); + } + + private isAnthropicModel(model?: string): boolean { + if (!model) { + return false; + } + const modelLower = model.toLowerCase(); + return modelLower.includes("claude") || modelLower.startsWith("anthropic/"); + } + + override modifyChatBody(body: T): T { + const modifiedBody = super.modifyChatBody(body); + + if (!this.isAnthropicModel(modifiedBody.model)) { + return modifiedBody; + } + + applyAnthropicCachingToOpenRouterBody( + modifiedBody as unknown as ChatCompletionCreateParams, + "systemAndTools", + ); + + return modifiedBody; + } +} + +export default RodiumAiApi; diff --git a/packages/openai-adapters/src/index.ts b/packages/openai-adapters/src/index.ts index 52fb2d33a0c..3258d11ba8d 100644 --- a/packages/openai-adapters/src/index.ts +++ b/packages/openai-adapters/src/index.ts @@ -18,6 +18,7 @@ import { MockApi } from "./apis/Mock.js"; import { MoonshotApi } from "./apis/Moonshot.js"; import { OpenAIApi } from "./apis/OpenAI.js"; import { OpenRouterApi } from "./apis/OpenRouter.js"; +import { RodiumAiApi } from "./apis/RodiumAi.js"; import { ClawRouterApi } from "./apis/ClawRouter.js"; import { RelaceApi } from "./apis/Relace.js"; import { VertexAIApi } from "./apis/VertexAI.js"; @@ -151,6 +152,8 @@ export function constructLlmApi(config: LLMConfig): BaseLlmApi | undefined { return openAICompatible("https://api.cerebras.ai/v1/", config); case "kindo": return openAICompatible("https://llm.kindo.ai/v1/", config); + case "rodiumai": + return new RodiumAiApi(config); case "msty": return openAICompatible("http://localhost:10000", config); case "nvidia": diff --git a/packages/openai-adapters/src/types.ts b/packages/openai-adapters/src/types.ts index 14b9512f75b..b0f8ec78c53 100644 --- a/packages/openai-adapters/src/types.ts +++ b/packages/openai-adapters/src/types.ts @@ -50,6 +50,7 @@ export const OpenAIConfigSchema = BasePlusConfig.extend({ z.literal("ollama"), z.literal("cerebras"), z.literal("kindo"), + z.literal("rodiumai"), z.literal("msty"), z.literal("openrouter"), z.literal("clawrouter"), diff --git a/scripts/ci-local-pr-files.txt b/scripts/ci-local-pr-files.txt new file mode 100644 index 00000000000..729fc1f7e2a --- /dev/null +++ b/scripts/ci-local-pr-files.txt @@ -0,0 +1,17 @@ +# Fichiers à valider (Prettier + scope PR RodiumAi) — un chemin par ligne +core/llm/autodetect.ts +core/llm/fetchModels.ts +core/llm/fetchRodiumAiModels.ts +core/llm/fetchRodiumAiModels.test.ts +core/llm/llms/RodiumAi.ts +core/llm/llms/RodiumAi.vitest.ts +core/llm/llms/index.ts +packages/openai-adapters/src/apis/RodiumAi.ts +packages/openai-adapters/src/index.ts +packages/openai-adapters/src/types.ts +gui/src/forms/AddModelForm.tsx +gui/src/pages/AddNewModel/configs/fetchProviderModels.ts +gui/src/pages/AddNewModel/configs/providers.ts +extensions/vscode/config_schema.json +docs/customize/model-providers/more/rodiumai.mdx +docs/customize/model-providers/overview.mdx diff --git a/scripts/ci-local.Dockerfile b/scripts/ci-local.Dockerfile new file mode 100644 index 00000000000..4301e2e343b --- /dev/null +++ b/scripts/ci-local.Dockerfile @@ -0,0 +1,13 @@ +FROM node:20.20.1-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Default: run CI script (repo mounted at /workspace) +CMD ["bash", "scripts/ci-local.sh"] diff --git a/scripts/ci-local.ps1 b/scripts/ci-local.ps1 new file mode 100644 index 00000000000..e7502c61301 --- /dev/null +++ b/scripts/ci-local.ps1 @@ -0,0 +1,89 @@ +# Local CI - mirrors .github/workflows/pr-checks.yaml (verify jobs) +# +# Usage (from continue/): +# powershell -File scripts/ci-local.ps1 +# powershell -File scripts/ci-local.ps1 -Docker +# powershell -File scripts/ci-local.ps1 -Docker -Full +# powershell -File scripts/ci-local.ps1 -Fix + +param( + [switch]$Docker, + [switch]$Full, + [switch]$FullPackages, + [switch]$FullPrettier, + [switch]$Fix +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "==> $Message" -ForegroundColor Cyan +} + +if ($Fix) { + Write-Step "Prettier write (auto-format)" + npx prettier --write "**/*.{js,jsx,ts,tsx,json,css,md}" --ignore-path .gitignore --ignore-path .prettierignore + if ($LASTEXITCODE -ne 0) { throw "Prettier write failed" } +} + +if ($Docker) { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw "Docker requis pour -Docker" + } + + Write-Step "Build image continue-ci-local" + docker build -f scripts/ci-local.Dockerfile -t continue-ci-local . + if ($LASTEXITCODE -ne 0) { throw "docker build failed" } + + $mount = (Get-Location).Path + $args = @( + "run", "--rm", + "-e", "IGNORE_API_KEY_TESTS=true", + "-e", "CI=true", + "-e", "FORCE_NPM_CI=true", + "-v", "${mount}:/workspace", + "-w", "/workspace", + "continue-ci-local" + ) + if ($FullPrettier) { + $args += @("-e", "PRETTIER_SCOPE=all") + } + $args += "bash" + $args += "scripts/ci-local.sh" + if ($Full) { + $args += "--full" + } + if ($FullPackages) { + $args += "--full-packages" + } + + Write-Step "Run CI in Docker (Linux / Node 20.20.1)" + docker @args + if ($LASTEXITCODE -ne 0) { throw "CI Docker failed (exit $LASTEXITCODE)" } + + Write-Host "" + Write-Host "CI Docker OK" -ForegroundColor Green + exit 0 +} + +Write-Step "Run CI natif (Git Bash)" +$bash = Get-Command bash -ErrorAction SilentlyContinue +if (-not $bash) { + throw "bash introuvable. Installez Git Bash ou utilisez -Docker" +} + +$ciArgs = @("scripts/ci-local.sh") +if ($Full) { $ciArgs += "--full" } +if ($FullPackages) { $ciArgs += "--full-packages" } + +$env:IGNORE_API_KEY_TESTS = "true" +$env:CI = "true" +& bash @ciArgs +if ($LASTEXITCODE -ne 0) { throw "CI failed (exit $LASTEXITCODE)" } + +Write-Host "" +Write-Host "CI locale OK" -ForegroundColor Green diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh new file mode 100644 index 00000000000..663b890793e --- /dev/null +++ b/scripts/ci-local.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Local CI mirroring .github/workflows/pr-checks.yaml (verify jobs only). +# +# Usage (from continue/): +# bash scripts/ci-local.sh # PR RodiumAi (recommandé avant commit) +# bash scripts/ci-local.sh --full # suite complète (comme GitHub CI) +# bash scripts/ci-local.sh --full-packages +# IGNORE_API_KEY_TESTS=false bash scripts/ci-local.sh --full + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CI_MODE="rodiumai" +FULL_PACKAGES=false +for arg in "$@"; do + case "$arg" in + --rodiumai) CI_MODE="rodiumai" ;; + --full) CI_MODE="full" ;; + --full-packages) FULL_PACKAGES=true ;; + -h | --help) + echo "Usage: bash scripts/ci-local.sh [--rodiumai|--full] [--full-packages]" + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + exit 1 + ;; + esac +done + +export IGNORE_API_KEY_TESTS="${IGNORE_API_KEY_TESTS:-true}" +export CI=true +PRETTIER_SCOPE="${PRETTIER_SCOPE:-pr}" +PR_FILES_MANIFEST="${PR_FILES_MANIFEST:-scripts/ci-local-pr-files.txt}" + +step() { + echo "" + echo "==> $1" +} + +fail() { + echo "CI FAILED at: $1" >&2 + exit 1 +} + +run() { + echo "+ $*" + "$@" || fail "$1" +} + +step "Node $(node -v) | mode=$CI_MODE | IGNORE_API_KEY_TESTS=$IGNORE_API_KEY_TESTS" + +if [[ "${FORCE_NPM_CI:-false}" == "true" ]]; then + step "Clean node_modules for Linux reinstall" + rm -rf core/node_modules gui/node_modules binary/node_modules extensions/vscode/node_modules +fi + +step "Install root dependencies" +if [[ ! -d node_modules ]]; then + run npm ci +fi + +step "Prettier check ($PRETTIER_SCOPE)" +if [[ "$PRETTIER_SCOPE" == "all" ]]; then + run npx prettier --check "**/*.{js,jsx,ts,tsx,json,css,md}" --ignore-path .gitignore --ignore-path .prettierignore +elif [[ "$PRETTIER_SCOPE" == "pr" && -f "$PR_FILES_MANIFEST" ]]; then + mapfile -t CHANGED_FILES < <( + grep -Ev '^\s*(#|$)' "$PR_FILES_MANIFEST" | while read -r f; do + [[ -f "$f" ]] && echo "$f" + done + ) + if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then + echo "No PR manifest files found; skipping Prettier." + else + echo "Checking ${#CHANGED_FILES[@]} PR file(s) from $PR_FILES_MANIFEST" + run npx prettier --check "${CHANGED_FILES[@]}" + fi +else + echo "Unknown PRETTIER_SCOPE=$PRETTIER_SCOPE; skipping Prettier." +fi + +step "Build packages" +run node ./scripts/build-packages.js + +step "Core: install" +if [[ "${FORCE_NPM_CI:-false}" == "true" || ! -d core/node_modules ]]; then + run bash -c "cd core && npm ci" +fi + +step "Core: typecheck + lint" +run bash -c "cd core && npx tsc --noEmit" +run bash -c "cd core && npm run lint" + +if [[ "$CI_MODE" == "rodiumai" ]]; then + step "Core: RodiumAi tests (jest + vitest)" + run bash -c "cd core && npm test -- fetchRodiumAiModels.test.ts" + run bash -c "cd core && npm run vitest -- llm/llms/RodiumAi.vitest.ts llm/llms/OpenRouter.vitest.ts" +else + step "Core: jest + vitest (full)" + run bash -c "cd core && npm test" + run bash -c "cd core && npm run vitest" +fi + +if [[ "$CI_MODE" == "full" ]]; then + step "GUI: install" + if [[ "${FORCE_NPM_CI:-false}" == "true" || ! -d gui/node_modules ]]; then + run bash -c "cd gui && npm ci" + fi + + step "GUI: typecheck + lint + tests" + run bash -c "cd gui && npx tsc --noEmit" + run bash -c "cd gui && npm run lint" + run bash -c "cd gui && npm test" + + step "Binary: install" + if [[ "${FORCE_NPM_CI:-false}" == "true" || ! -d binary/node_modules ]]; then + run bash -c "cd binary && npm ci" + fi + + step "Binary: typecheck" + run bash -c "cd binary && npx tsc --noEmit" + + step "VS Code extension: install" + if [[ "${FORCE_NPM_CI:-false}" == "true" || ! -d extensions/vscode/node_modules ]]; then + run bash -c "cd extensions/vscode && npm ci" + fi + + step "VS Code extension: typecheck + lint + vitest" + run bash -c "cd extensions/vscode && npm run write-build-timestamp" + run bash -c "cd extensions/vscode && npx tsc --noEmit" + run bash -c "cd extensions/vscode && npm run lint" + run bash -c "cd extensions/vscode && npm run vitest" +else + step "GUI: typecheck (PR RodiumAi)" + if [[ "${FORCE_NPM_CI:-false}" == "true" || ! -d gui/node_modules ]]; then + run bash -c "cd gui && npm ci" + fi + run bash -c "cd gui && npx tsc --noEmit" +fi + +PACKAGES=(openai-adapters) +if [[ "$FULL_PACKAGES" == "true" ]]; then + PACKAGES=(config-types config-yaml continue-sdk fetch llm-info openai-adapters terminal-security) +fi + +for pkg in "${PACKAGES[@]}"; do + step "Package $pkg: install + test" + run bash -c "cd packages/$pkg && npm ci" + if [[ "$pkg" == "openai-adapters" && "$IGNORE_API_KEY_TESTS" == "true" ]]; then + run bash -c "cd packages/$pkg && npm test -- --run --exclude '**/vercel-sdk.test.ts'" + else + run bash -c "cd packages/$pkg && npm test" + fi +done + +echo "" +if [[ "$CI_MODE" == "rodiumai" ]]; then + echo "CI locale OK (mode PR RodiumAi: prettier, core, gui tsc, openai-adapters)." + echo "Pour la suite complète GitHub: bash scripts/ci-local.sh --full (ou -Full sur ps1)." +else + echo "CI locale OK (mode full: prettier, core, gui, binary, vscode, packages)." +fi +echo "Non exécuté localement: vscode-e2e-tests, jetbrains-tests (infra CI GitHub)."