Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const dynamicProviders = [
"requesty",
"roo",
"unbound",
"futurmix",
"poe",
] as const

Expand Down Expand Up @@ -344,6 +345,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(),
})
Expand Down Expand Up @@ -418,6 +425,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") })),
Expand Down Expand Up @@ -452,6 +460,7 @@ export const providerSettingsSchema = z.object({
...minimaxSchema.shape,
...requestySchema.shape,
...unboundSchema.shape,
...futurmixSchema.shape,
...fakeAiSchema.shape,
...xaiSchema.shape,
...basetenSchema.shape,
Expand Down Expand Up @@ -490,6 +499,7 @@ export const modelIdKeys = [
"lmStudioDraftModelId",
"requestyModelId",
"unboundModelId",
"futurmixModelId",
"litellmModelId",
"vercelAiGatewayModelId",
] as const satisfies readonly (keyof ProviderSettings)[]
Expand Down Expand Up @@ -529,6 +539,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
"qwen-code": "apiModelId",
requesty: "requestyModelId",
unbound: "unboundModelId",
futurmix: "futurmixModelId",
xai: "apiModelId",
baseten: "apiModelId",
litellm: "litellmModelId",
Expand Down Expand Up @@ -653,6 +664,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.
Expand Down
16 changes: 16 additions & 0 deletions packages/types/src/providers/futurmix.ts
Original file line number Diff line number Diff line change
@@ -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,
}
4 changes: 4 additions & 0 deletions packages/types/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from "./requesty.js"
export * from "./roo.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"
Expand All @@ -43,6 +44,7 @@ import { requestyDefaultModelId } from "./requesty.js"
import { rooDefaultModelId } from "./roo.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"
Expand Down Expand Up @@ -113,6 +115,8 @@ export function getProviderDefaultModelId(
return poeDefaultModelId
case "unbound":
return unboundDefaultModelId
case "futurmix":
return futurmixDefaultModelId
case "vercel-ai-gateway":
return vercelAiGatewayDefaultModelId
case "anthropic":
Expand Down
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
VsCodeLmHandler,
RequestyHandler,
UnboundHandler,
FuturMixHandler,
FakeAIHandler,
XAIHandler,
LiteLLMHandler,
Expand Down Expand Up @@ -155,6 +156,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":
Expand Down
40 changes: 40 additions & 0 deletions src/api/providers/fetchers/futurmix.ts
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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"
Expand Down Expand Up @@ -73,6 +74,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)
Expand Down
188 changes: 188 additions & 0 deletions src/api/providers/futurmix.ts
Original file line number Diff line number Diff line change
@@ -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 || ""
}
}
1 change: 1 addition & 0 deletions src/api/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,7 @@ export const webviewMessageHandler = async (
litellm: {},
requesty: {},
unbound: {},
futurmix: {},
ollama: {},
lmstudio: {},
roo: {},
Expand Down Expand Up @@ -989,6 +990,13 @@ export const webviewMessageHandler = async (
apiKey: apiConfiguration.unboundApiKey,
},
},
{
key: "futurmix",
options: {
provider: "futurmix",
apiKey: apiConfiguration.futurmixApiKey,
},
},
{ key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } },
{
key: "roo",
Expand Down
Loading