feat: add FuturMix provider

Add FuturMix (https://futurmix.ai) as an OpenAI-compatible provider —
unified gateway for 25+ frontier models (Claude, GPT, Gemini, DeepSeek, Kimi).

- packages/types: provider schema, settings, dynamic-provider registration
- src/api: FuturMix handler + model fetcher (OpenAI-compatible /v1)
- webview-ui: settings panel, model selection, validation, i18n
This commit is contained in:
FuturMix 2026-07-27 11:17:34 +08:00
parent b867ec9145
commit a3e0173af6
20 changed files with 425 additions and 1 deletions

View file

@ -34,7 +34,15 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
* Dynamic provider requires external API calls in order to get the model list.
*/
export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "unbound", "poe"] as const
export const dynamicProviders = [
"openrouter",
"vercel-ai-gateway",
"litellm",
"requesty",
"unbound",
"futurmix",
"poe",
] as const
export type DynamicProvider = (typeof dynamicProviders)[number]
@ -336,6 +344,12 @@ const unboundSchema = baseProviderSettingsSchema.extend({
unboundModelId: z.string().optional(),
})
const futurmixSchema = baseProviderSettingsSchema.extend({
futurmixApiKey: z.string().optional(),
futurmixBaseUrl: z.string().optional(),
futurmixModelId: z.string().optional(),
})
const fakeAiSchema = baseProviderSettingsSchema.extend({
fakeAi: z.unknown().optional(),
})
@ -405,6 +419,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })),
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
futurmixSchema.merge(z.object({ apiProvider: z.literal("futurmix") })),
fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })),
xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })),
basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })),
@ -438,6 +453,7 @@ export const providerSettingsSchema = z.object({
...minimaxSchema.shape,
...requestySchema.shape,
...unboundSchema.shape,
...futurmixSchema.shape,
...fakeAiSchema.shape,
...xaiSchema.shape,
...basetenSchema.shape,
@ -475,6 +491,7 @@ export const modelIdKeys = [
"lmStudioDraftModelId",
"requestyModelId",
"unboundModelId",
"futurmixModelId",
"litellmModelId",
"vercelAiGatewayModelId",
] as const satisfies readonly (keyof ProviderSettings)[]
@ -514,6 +531,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
"qwen-code": "apiModelId",
requesty: "requestyModelId",
unbound: "unboundModelId",
futurmix: "futurmixModelId",
xai: "apiModelId",
baseten: "apiModelId",
litellm: "litellmModelId",
@ -631,6 +649,7 @@ export const MODELS_BY_PROVIDER: Record<
openrouter: { id: "openrouter", label: "OpenRouter", models: [] },
requesty: { id: "requesty", label: "Requesty", models: [] },
unbound: { id: "unbound", label: "Unbound", models: [] },
futurmix: { id: "futurmix", label: "FuturMix", models: [] },
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
// Local providers; models discovered from localhost endpoints.

View file

@ -0,0 +1,16 @@
import type { ModelInfo } from "../model.js"
// FuturMix
// https://futurmix.ai
export const futurmixDefaultModelId = "claude-sonnet-4-20250514"
export const futurmixDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}

View file

@ -18,6 +18,7 @@ export * from "./qwen-code.js"
export * from "./requesty.js"
export * from "./sambanova.js"
export * from "./unbound.js"
export * from "./futurmix.js"
export * from "./vertex.js"
export * from "./vscode-llm.js"
export * from "./xai.js"
@ -41,6 +42,7 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js"
import { requestyDefaultModelId } from "./requesty.js"
import { sambaNovaDefaultModelId } from "./sambanova.js"
import { unboundDefaultModelId } from "./unbound.js"
import { futurmixDefaultModelId } from "./futurmix.js"
import { vertexDefaultModelId } from "./vertex.js"
import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
import { xaiDefaultModelId } from "./xai.js"
@ -109,6 +111,8 @@ export function getProviderDefaultModelId(
return poeDefaultModelId
case "unbound":
return unboundDefaultModelId
case "futurmix":
return futurmixDefaultModelId
case "vercel-ai-gateway":
return vercelAiGatewayDefaultModelId
case "anthropic":

View file

@ -23,6 +23,7 @@ import {
VsCodeLmHandler,
RequestyHandler,
UnboundHandler,
FuturMixHandler,
FakeAIHandler,
XAIHandler,
LiteLLMHandler,
@ -157,6 +158,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new RequestyHandler(options)
case "unbound":
return new UnboundHandler(options)
case "futurmix":
return new FuturMixHandler(options)
case "fake-ai":
return new FakeAIHandler(options)
case "xai":

View file

@ -0,0 +1,40 @@
import axios from "axios"
import type { ModelInfo } from "@roo-code/types"
import { parseApiPrice } from "../../../shared/cost"
export async function getFuturMixModels(apiKey?: string | null): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
try {
const headers: Record<string, string> = {}
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`
}
const response = await axios.get("https://futurmix.ai/v1/models", { headers })
const rawModels = response.data?.data ?? response.data
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.max_output_tokens ?? 8192,
contextWindow: rawModel.context_window ?? 200_000,
supportsPromptCache: rawModel.supports_caching ?? false,
supportsImages: rawModel.supports_vision ?? false,
inputPrice: parseApiPrice(rawModel.input_price),
outputPrice: parseApiPrice(rawModel.output_price),
description: rawModel.description,
cacheWritesPrice: parseApiPrice(rawModel.caching_price),
cacheReadsPrice: parseApiPrice(rawModel.cached_price),
}
models[rawModel.id] = modelInfo
}
} catch (error) {
console.error(`Error fetching FuturMix models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
}
return models
}

View file

@ -19,6 +19,7 @@ import { getOpenRouterModels } from "./openrouter"
import { getVercelAiGatewayModels } from "./vercel-ai-gateway"
import { getRequestyModels } from "./requesty"
import { getUnboundModels } from "./unbound"
import { getFuturMixModels } from "./futurmix"
import { getLiteLLMModels } from "./litellm"
import { GetModelsOptions } from "../../../shared/api"
import { getOllamaModels } from "./ollama"
@ -71,6 +72,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
case "unbound":
models = await getUnboundModels(options.apiKey)
break
case "futurmix":
models = await getFuturMixModels(options.apiKey)
break
case "litellm":
// Type safety ensures apiKey and baseUrl are always provided for LiteLLM.
models = await getLiteLLMModels(options.apiKey, options.baseUrl)

View file

@ -0,0 +1,188 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { type ModelInfo, type ModelRecord, futurmixDefaultModelId, futurmixDefaultModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { DEFAULT_HEADERS } from "./constants"
import { getModels } from "./fetchers/modelCache"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { handleOpenAIError } from "./utils/openai-error-handler"
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
// FuturMix usage includes extra fields for Anthropic cache tokens.
interface FuturMixUsage extends OpenAI.CompletionUsage {
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
export class FuturMixHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
protected models: ModelRecord = {}
private client: OpenAI
private readonly providerName = "FuturMix"
constructor(options: ApiHandlerOptions) {
super()
this.options = options
const apiKey = this.options.futurmixApiKey ?? "not-provided"
this.client = new OpenAI({
baseURL: this.options.futurmixBaseUrl || "https://futurmix.ai/v1",
apiKey: apiKey,
defaultHeaders: DEFAULT_HEADERS,
})
}
public async fetchModel() {
this.models = await getModels({ provider: "futurmix", apiKey: this.options.futurmixApiKey })
return this.getModel()
}
override getModel() {
const id = this.options.futurmixModelId ?? futurmixDefaultModelId
const cachedInfo = this.models[id] ?? futurmixDefaultModelInfo
let info: ModelInfo = cachedInfo
// Apply tool preferences for models accessed through routers (OpenAI, Gemini)
info = applyRouterToolPreferences(id, info)
const params = getModelParams({
format: "openai",
modelId: id,
model: info,
settings: this.options,
defaultTemperature: 0,
})
return { id, info, ...params }
}
protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk {
const futurmixUsage = usage as FuturMixUsage
const inputTokens = futurmixUsage?.prompt_tokens || 0
const outputTokens = futurmixUsage?.completion_tokens || 0
const cacheWriteTokens = futurmixUsage?.cache_creation_input_tokens || 0
const cacheReadTokens = futurmixUsage?.cache_read_input_tokens || 0
const { totalCost } = modelInfo
? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
: { totalCost: 0 }
return {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const {
id: model,
info,
maxTokens: max_tokens,
temperature,
reasoningEffort: reasoning_effort,
reasoning: thinking,
} = await this.fetchModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// Map extended efforts to OpenAI Chat Completions-accepted values (omit unsupported)
const allowedEffort = (["low", "medium", "high"] as const).includes(reasoning_effort as any)
? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"])
: undefined
const completionParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
messages: openAiMessages,
model,
max_tokens,
temperature,
...(allowedEffort && { reasoning_effort: allowedEffort }),
stream: true,
stream_options: { include_usage: true },
tools: this.convertToolsForOpenAI(metadata?.tools),
tool_choice: metadata?.tool_choice,
}
let stream
try {
stream = await this.client.chat.completions.create(completionParams)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
let lastUsage: any = undefined
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield { type: "text", text: delta.content }
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" }
}
// Handle native tool calls
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
for (const toolCall of delta.tool_calls) {
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
}
if (chunk.usage) {
lastUsage = chunk.usage
}
}
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, info)
}
}
async completePrompt(prompt: string): Promise<string> {
const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }]
const completionParams: OpenAI.Chat.ChatCompletionCreateParams = {
model,
max_tokens,
messages: openAiMessages,
temperature: temperature,
}
let response: OpenAI.Chat.ChatCompletion
try {
response = await this.client.chat.completions.create(completionParams)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
return response.choices[0]?.message.content || ""
}
}

View file

@ -19,6 +19,7 @@ export { QwenCodeHandler } from "./qwen-code"
export { RequestyHandler } from "./requesty"
export { SambaNovaHandler } from "./sambanova"
export { UnboundHandler } from "./unbound"
export { FuturMixHandler } from "./futurmix"
export { VertexHandler } from "./vertex"
export { VsCodeLmHandler } from "./vscode-lm"
export { XAIHandler } from "./xai"

View file

@ -890,6 +890,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
litellm: {},
requesty: {},
unbound: {},
futurmix: {},
ollama: {},
lmstudio: {},
poe: {},
@ -926,6 +927,13 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
apiKey: apiConfiguration.unboundApiKey,
},
},
{
key: "futurmix",
options: {
provider: "futurmix",
apiKey: apiConfiguration.futurmixApiKey,
},
},
{ key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } },
]

View file

@ -79,6 +79,8 @@ export class ProfileValidator {
return profile.requestyModelId
case "unbound":
return profile.unboundModelId
case "futurmix":
return profile.futurmixModelId
case "fake-ai":
default:
return undefined

View file

@ -174,6 +174,7 @@ const dynamicProviderExtras = {
litellm: {} as { apiKey: string; baseUrl: string },
requesty: {} as { apiKey?: string; baseUrl?: string },
unbound: {} as { apiKey?: string },
futurmix: {} as { apiKey?: string },
ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
poe: {} as { apiKey?: string; baseUrl?: string },

View file

@ -32,6 +32,7 @@ import {
vercelAiGatewayDefaultModelId,
minimaxDefaultModelId,
unboundDefaultModelId,
futurmixDefaultModelId,
} from "@roo-code/types"
import {
@ -85,6 +86,7 @@ import {
Requesty,
SambaNova,
Unbound,
FuturMix,
Vertex,
VSCodeLM,
XAI,
@ -334,6 +336,7 @@ const ApiOptions = ({
openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId },
requesty: { field: "requestyModelId", default: requestyDefaultModelId },
unbound: { field: "unboundModelId", default: unboundDefaultModelId },
futurmix: { field: "futurmixModelId", default: futurmixDefaultModelId },
litellm: { field: "litellmModelId", default: litellmDefaultModelId },
anthropic: { field: "apiModelId", default: anthropicDefaultModelId },
"openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId },
@ -522,6 +525,18 @@ const ApiOptions = ({
/>
)}
{selectedProvider === "futurmix" && (
<FuturMix
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
refetchRouterModels={refetchRouterModels}
organizationAllowList={organizationAllowList}
modelValidationError={modelValidationError}
simplifySettings={fromWelcomeView}
/>
)}
{selectedProvider === "anthropic" && (
<Anthropic
apiConfiguration={apiConfiguration}

View file

@ -32,6 +32,7 @@ type ModelIdKey = keyof Pick<
| "requestyModelId"
| "unboundModelId"
| "openAiModelId"
| "futurmixModelId"
| "litellmModelId"
| "vercelAiGatewayModelId"
| "apiModelId"

View file

@ -64,5 +64,6 @@ export const PROVIDERS = [
{ value: "minimax", label: "MiniMax", proxy: false },
{ value: "baseten", label: "Baseten", proxy: false },
{ value: "unbound", label: "Unbound", proxy: false },
{ value: "futurmix", label: "FuturMix", proxy: false },
{ value: "poe", label: "Poe", proxy: false },
].sort((a, b) => a.label.localeCompare(b.label))

View file

@ -0,0 +1,108 @@
import { useCallback } from "react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import {
type ProviderSettings,
type OrganizationAllowList,
type RouterModels,
futurmixDefaultModelId,
} from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Button } from "@src/components/ui"
import { inputEventTransform } from "../transforms"
import { ModelPicker } from "../ModelPicker"
type FuturMixProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
routerModels?: RouterModels
refetchRouterModels: () => void
organizationAllowList: OrganizationAllowList
modelValidationError?: string
simplifySettings?: boolean
}
export const FuturMix = ({
apiConfiguration,
setApiConfigurationField,
routerModels,
organizationAllowList,
modelValidationError,
simplifySettings,
}: FuturMixProps) => {
const { t } = useAppTranslation()
const handleInputChange = useCallback(
<K extends keyof ProviderSettings, E>(
field: K,
transform: (event: E) => ProviderSettings[K] = inputEventTransform,
) =>
(event: E | Event) => {
setApiConfigurationField(field, transform(event as E))
},
[setApiConfigurationField],
)
return (
<>
<VSCodeTextField
value={apiConfiguration?.futurmixApiKey || ""}
type="password"
onInput={handleInputChange("futurmixApiKey")}
placeholder={t("settings:providers.apiKey")}
className="w-full">
<div className="flex justify-between items-center mb-1">
<label className="block font-medium">{t("settings:providers.apiKey")}</label>
</div>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground -mt-2">
{t("settings:providers.apiKeyStorageNotice")}
</div>
<a
href="https://futurmix.ai?utm_source=github_roo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center whitespace-nowrap text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 rounded-md px-3 w-full"
style={{
width: "100%",
textDecoration: "none",
color: "var(--vscode-button-foreground)",
backgroundColor: "var(--vscode-button-background)",
}}>
{t("settings:providers.getFuturMixApiKey")}
</a>
<VSCodeTextField
value={apiConfiguration?.futurmixBaseUrl || ""}
onInput={handleInputChange("futurmixBaseUrl")}
placeholder="https://futurmix.ai/v1"
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.openAiBaseUrl")}</label>
</VSCodeTextField>
<Button
variant="outline"
onClick={() => {
vscode.postMessage({ type: "requestRouterModels", values: { provider: "futurmix", refresh: true } })
}}>
<div className="flex items-center gap-2">
<span className="codicon codicon-refresh" />
{t("settings:providers.refreshModels.label")}
</div>
</Button>
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={futurmixDefaultModelId}
models={routerModels?.futurmix ?? {}}
modelIdKey="futurmixModelId"
serviceName="FuturMix"
serviceUrl="https://futurmix.ai/v1/models"
organizationAllowList={organizationAllowList}
errorMessage={modelValidationError}
simplifySettings={simplifySettings}
/>
</>
)
}

View file

@ -15,6 +15,7 @@ export { QwenCode } from "./QwenCode"
export { Requesty } from "./Requesty"
export { SambaNova } from "./SambaNova"
export { Unbound } from "./Unbound"
export { FuturMix } from "./FuturMix"
export { Vertex } from "./Vertex"
export { VSCodeLM } from "./VSCodeLM"
export { XAI } from "./XAI"

View file

@ -164,6 +164,11 @@ function getSelectedModel({
const routerInfo = routerModels.unbound?.[id]
return { id, info: routerInfo }
}
case "futurmix": {
const id = getValidatedModelId(apiConfiguration.futurmixModelId, routerModels.futurmix, defaultModelId)
const routerInfo = routerModels.futurmix?.[id]
return { id, info: routerInfo }
}
case "litellm": {
const id = getValidatedModelId(apiConfiguration.litellmModelId, routerModels.litellm, defaultModelId)
const routerInfo = routerModels.litellm?.[id]

View file

@ -412,6 +412,7 @@
"noCustomHeaders": "No custom headers defined. Click the + button to add one.",
"unboundApiKey": "Unbound API Key",
"getUnboundApiKey": "Get Unbound API Key",
"getFuturMixApiKey": "Get FuturMix API Key",
"requestyApiKey": "Requesty API Key",
"refreshModels": {
"label": "Refresh Models",

View file

@ -40,6 +40,7 @@ describe("Model Validation Functions", () => {
},
requesty: {},
unbound: {},
futurmix: {},
litellm: {},
ollama: {},
lmstudio: {},

View file

@ -53,6 +53,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri
return i18next.t("settings:validation.apiKey")
}
break
case "futurmix":
if (!apiConfiguration.futurmixApiKey) {
return i18next.t("settings:validation.apiKey")
}
break
case "litellm":
if (!apiConfiguration.litellmApiKey) {
return i18next.t("settings:validation.apiKey")