diff --git a/.env.example b/.env.example index f43a82e81..bd9b8bde0 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ GOOGLE_CLOUD_LOCATION=global GOOGLE_CLOUD_PROJECT=$(op read "op://RubyLLM/Google Cloud/project") GPUSTACK_API_BASE=http://localhost:11444/v1 GPUSTACK_API_KEY=$(op read "op://RubyLLM/GPUStack/credential") +MINIMAX_API_KEY=$(op read "op://RubyLLM/MiniMax/credential") MISTRAL_API_KEY=$(op read "op://RubyLLM/Mistral/credential") OLLAMA_API_BASE=http://localhost:11434/v1 OPENAI_API_KEY=$(op read "op://RubyLLM/OpenAI/credential") diff --git a/README.md b/README.md index 40bd89c95..4746c96cf 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ response = chat.with_schema(ProductSchema).ask "Analyze this product", with: "pr * **Async:** Fiber-based concurrency * **Model registry:** 800+ models with capability detection and pricing * **Extended thinking:** Control, view, and persist model deliberation -* **Providers:** OpenAI, xAI, Anthropic, Gemini, VertexAI, Bedrock, DeepSeek, Mistral, Ollama, OpenRouter, Perplexity, GPUStack, and any OpenAI-compatible API +* **Providers:** OpenAI, xAI, Anthropic, Gemini, VertexAI, Bedrock, DeepSeek, MiniMax, Mistral, Ollama, OpenRouter, Perplexity, GPUStack, and any OpenAI-compatible API ## Installation diff --git a/docs/_getting_started/configuration.md b/docs/_getting_started/configuration.md index b5686cb2e..fd0fb5ae6 100644 --- a/docs/_getting_started/configuration.md +++ b/docs/_getting_started/configuration.md @@ -76,6 +76,10 @@ RubyLLM.configure do |config| config.gpustack_api_base = ENV['GPUSTACK_API_BASE'] config.gpustack_api_key = ENV['GPUSTACK_API_KEY'] + # MiniMax + config.minimax_api_key = ENV['MINIMAX_API_KEY'] + config.minimax_api_base = ENV['MINIMAX_API_BASE'] # Optional custom MiniMax endpoint (defaults to https://api.minimax.io/v1) + # Mistral config.mistral_api_key = ENV['MISTRAL_API_KEY'] @@ -477,6 +481,10 @@ RubyLLM.configure do |config| config.gpustack_api_base = String config.gpustack_api_key = String + # MiniMax + config.minimax_api_key = String + config.minimax_api_base = String + # Mistral config.mistral_api_key = String diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index 87bc94c9d..4a22874df 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -22,6 +22,7 @@ 'deepseek' => 'DeepSeek', 'gpustack' => 'GPUStack', 'llm' => 'LLM', + 'minimax' => 'MiniMax', 'mistral' => 'Mistral', 'openai' => 'OpenAI', 'openrouter' => 'OpenRouter', @@ -99,6 +100,7 @@ def logger RubyLLM::Provider.register :deepseek, RubyLLM::Providers::DeepSeek RubyLLM::Provider.register :gemini, RubyLLM::Providers::Gemini RubyLLM::Provider.register :gpustack, RubyLLM::Providers::GPUStack +RubyLLM::Provider.register :minimax, RubyLLM::Providers::MiniMax RubyLLM::Provider.register :mistral, RubyLLM::Providers::Mistral RubyLLM::Provider.register :ollama, RubyLLM::Providers::Ollama RubyLLM::Provider.register :openai, RubyLLM::Providers::OpenAI diff --git a/lib/ruby_llm/providers/minimax.rb b/lib/ruby_llm/providers/minimax.rb new file mode 100644 index 000000000..91405317b --- /dev/null +++ b/lib/ruby_llm/providers/minimax.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module RubyLLM + module Providers + # MiniMax API integration. + # MiniMax provides an OpenAI-compatible chat completions API at https://api.minimax.io/v1 + class MiniMax < OpenAI + include MiniMax::Chat + include MiniMax::Models + + def api_base + @config.minimax_api_base || 'https://api.minimax.io/v1' + end + + def headers + { + 'Authorization' => "Bearer #{@config.minimax_api_key}", + 'Content-Type' => 'application/json' + } + end + + def maybe_normalize_temperature(temperature, _model) + MiniMax::Temperature.normalize(temperature) + end + + class << self + def capabilities + MiniMax::Capabilities + end + + def configuration_options + %i[minimax_api_key minimax_api_base] + end + + def configuration_requirements + %i[minimax_api_key] + end + end + end + end +end diff --git a/lib/ruby_llm/providers/minimax/capabilities.rb b/lib/ruby_llm/providers/minimax/capabilities.rb new file mode 100644 index 000000000..a7a8bc173 --- /dev/null +++ b/lib/ruby_llm/providers/minimax/capabilities.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module RubyLLM + module Providers + class MiniMax + # Determines capabilities and pricing for MiniMax models + module Capabilities + module_function + + def context_window_for(model_id) + case model_id + when /M2\.7/ then 1_000_000 + when /M2\.5/ then 204_000 + else 204_000 + end + end + + def max_tokens_for(model_id) + case model_id + when /M2\.7/ then 16_384 + when /M2\.5/ then 16_384 + else 8_192 + end + end + + def input_price_for(model_id) + PRICES.dig(model_family(model_id), :input) || default_input_price + end + + def output_price_for(model_id) + PRICES.dig(model_family(model_id), :output) || default_output_price + end + + def supports_vision?(_model_id) + false + end + + def supports_functions?(model_id) + model_id.match?(/M2\.[57]/) + end + + def supports_tool_choice?(_model_id) + true + end + + def supports_tool_parallel_control?(_model_id) + false + end + + def supports_json_mode?(model_id) + model_id.match?(/M2\.[57]/) + end + + def format_display_name(model_id) + model_id + end + + def model_type(_model_id) + 'chat' + end + + def model_family(model_id) + case model_id + when /M2\.7-highspeed/ then :m2_7_highspeed + when /M2\.7/ then :m2_7 + when /M2\.5-highspeed/ then :m2_5_highspeed + when /M2\.5/ then :m2_5 + else :default + end + end + + PRICES = { + m2_7: { + input: 0.10, + output: 0.10 + }, + m2_7_highspeed: { + input: 0.07, + output: 0.07 + }, + m2_5: { + input: 0.10, + output: 0.10 + }, + m2_5_highspeed: { + input: 0.07, + output: 0.07 + } + }.freeze + + def default_input_price + 0.10 + end + + def default_output_price + 0.10 + end + + def modalities_for(_model_id) + { + input: ['text'], + output: ['text'] + } + end + + def capabilities_for(model_id) + capabilities = ['streaming'] + capabilities << 'function_calling' if supports_functions?(model_id) + capabilities << 'json_mode' if supports_json_mode?(model_id) + capabilities + end + + def pricing_for(model_id) + family = model_family(model_id) + prices = PRICES.fetch(family, { input: default_input_price, output: default_output_price }) + + { + text_tokens: { + standard: { + input_per_million: prices[:input], + output_per_million: prices[:output] + } + } + } + end + end + end + end +end diff --git a/lib/ruby_llm/providers/minimax/chat.rb b/lib/ruby_llm/providers/minimax/chat.rb new file mode 100644 index 000000000..2ab0baf95 --- /dev/null +++ b/lib/ruby_llm/providers/minimax/chat.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module RubyLLM + module Providers + class MiniMax + # Chat methods of the MiniMax API integration + module Chat + module_function + + def format_role(role) + role.to_s + end + end + end + end +end diff --git a/lib/ruby_llm/providers/minimax/models.rb b/lib/ruby_llm/providers/minimax/models.rb new file mode 100644 index 000000000..510fcc7cd --- /dev/null +++ b/lib/ruby_llm/providers/minimax/models.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module RubyLLM + module Providers + class MiniMax + # Models methods of the MiniMax API integration. + # MiniMax does not provide a /v1/models endpoint, + # so models are statically defined. + module Models + def list_models(**) + slug = 'minimax' + capabilities = MiniMax::Capabilities + parse_list_models_response(nil, slug, capabilities) + end + + def parse_list_models_response(_response, slug, capabilities) + model_ids.map do |model_id| + create_model_info(model_id, slug, capabilities) + end + end + + def model_ids + %w[ + MiniMax-M2.7 + MiniMax-M2.7-highspeed + MiniMax-M2.5 + MiniMax-M2.5-highspeed + ] + end + + def create_model_info(model_id, slug, capabilities) + Model::Info.new( + id: model_id, + name: capabilities.format_display_name(model_id), + provider: slug, + family: capabilities.model_family(model_id).to_s, + created_at: Time.now, + context_window: capabilities.context_window_for(model_id), + max_output_tokens: capabilities.max_tokens_for(model_id), + modalities: capabilities.modalities_for(model_id), + capabilities: capabilities.capabilities_for(model_id), + pricing: capabilities.pricing_for(model_id), + metadata: {} + ) + end + end + end + end +end diff --git a/lib/ruby_llm/providers/minimax/temperature.rb b/lib/ruby_llm/providers/minimax/temperature.rb new file mode 100644 index 000000000..b0fb5f44f --- /dev/null +++ b/lib/ruby_llm/providers/minimax/temperature.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyLLM + module Providers + class MiniMax + # Normalizes temperature for MiniMax models. + # MiniMax accepts temperature in the range [0.0, 1.0]. + module Temperature + module_function + + def normalize(temperature) + return temperature if temperature.nil? + + clamped = temperature.to_f.clamp(0.0, 1.0) + return clamped if (clamped - temperature.to_f).abs <= Float::EPSILON + + RubyLLM.logger.debug { "MiniMax requires temperature in [0.0, 1.0], clamping #{temperature} to #{clamped}" } + clamped + end + end + end + end +end diff --git a/spec/ruby_llm/providers/minimax/capabilities_spec.rb b/spec/ruby_llm/providers/minimax/capabilities_spec.rb new file mode 100644 index 000000000..9b53ab99b --- /dev/null +++ b/spec/ruby_llm/providers/minimax/capabilities_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::MiniMax::Capabilities do + describe '.context_window_for' do + it 'returns 1_000_000 for M2.7 models' do + expect(described_class.context_window_for('MiniMax-M2.7')).to eq(1_000_000) + end + + it 'returns 1_000_000 for M2.7-highspeed models' do + expect(described_class.context_window_for('MiniMax-M2.7-highspeed')).to eq(1_000_000) + end + + it 'returns 204_000 for M2.5 models' do + expect(described_class.context_window_for('MiniMax-M2.5')).to eq(204_000) + end + + it 'returns 204_000 for M2.5-highspeed models' do + expect(described_class.context_window_for('MiniMax-M2.5-highspeed')).to eq(204_000) + end + end + + describe '.max_tokens_for' do + it 'returns 16_384 for M2.7 models' do + expect(described_class.max_tokens_for('MiniMax-M2.7')).to eq(16_384) + end + + it 'returns 16_384 for M2.5 models' do + expect(described_class.max_tokens_for('MiniMax-M2.5')).to eq(16_384) + end + end + + describe '.model_family' do + it 'returns :m2_7 for M2.7' do + expect(described_class.model_family('MiniMax-M2.7')).to eq(:m2_7) + end + + it 'returns :m2_7_highspeed for M2.7-highspeed' do + expect(described_class.model_family('MiniMax-M2.7-highspeed')).to eq(:m2_7_highspeed) + end + + it 'returns :m2_5 for M2.5' do + expect(described_class.model_family('MiniMax-M2.5')).to eq(:m2_5) + end + + it 'returns :m2_5_highspeed for M2.5-highspeed' do + expect(described_class.model_family('MiniMax-M2.5-highspeed')).to eq(:m2_5_highspeed) + end + end + + describe '.supports_functions?' do + it 'returns true for M2.7 models' do + expect(described_class.supports_functions?('MiniMax-M2.7')).to be true + end + + it 'returns true for M2.5 models' do + expect(described_class.supports_functions?('MiniMax-M2.5')).to be true + end + end + + describe '.supports_json_mode?' do + it 'returns true for M2.7 models' do + expect(described_class.supports_json_mode?('MiniMax-M2.7')).to be true + end + + it 'returns true for M2.5 models' do + expect(described_class.supports_json_mode?('MiniMax-M2.5')).to be true + end + end + + describe '.supports_vision?' do + it 'returns false for all models' do + expect(described_class.supports_vision?('MiniMax-M2.7')).to be false + end + end + + describe '.model_type' do + it 'returns chat for all models' do + expect(described_class.model_type('MiniMax-M2.7')).to eq('chat') + end + end + + describe '.format_display_name' do + it 'returns the model id as-is' do + expect(described_class.format_display_name('MiniMax-M2.7')).to eq('MiniMax-M2.7') + end + end + + describe '.capabilities_for' do + it 'includes streaming for all models' do + expect(described_class.capabilities_for('MiniMax-M2.7')).to include('streaming') + end + + it 'includes function_calling for M2.7' do + expect(described_class.capabilities_for('MiniMax-M2.7')).to include('function_calling') + end + + it 'includes json_mode for M2.7' do + expect(described_class.capabilities_for('MiniMax-M2.7')).to include('json_mode') + end + end + + describe '.pricing_for' do + it 'returns pricing hash for M2.7' do + pricing = described_class.pricing_for('MiniMax-M2.7') + expect(pricing).to have_key(:text_tokens) + expect(pricing[:text_tokens][:standard]).to include(:input_per_million, :output_per_million) + end + end + + describe '.modalities_for' do + it 'returns text input and output' do + modalities = described_class.modalities_for('MiniMax-M2.7') + expect(modalities[:input]).to eq(['text']) + expect(modalities[:output]).to eq(['text']) + end + end +end diff --git a/spec/ruby_llm/providers/minimax/chat_spec.rb b/spec/ruby_llm/providers/minimax/chat_spec.rb new file mode 100644 index 000000000..b6ece1dd3 --- /dev/null +++ b/spec/ruby_llm/providers/minimax/chat_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::MiniMax::Chat do + describe '.format_role' do + it 'converts symbol roles to strings' do + expect(described_class.format_role(:user)).to eq('user') + end + + it 'passes through string roles' do + expect(described_class.format_role('assistant')).to eq('assistant') + end + + it 'converts system role to string' do + expect(described_class.format_role(:system)).to eq('system') + end + end +end diff --git a/spec/ruby_llm/providers/minimax/models_spec.rb b/spec/ruby_llm/providers/minimax/models_spec.rb new file mode 100644 index 000000000..c2bca2ecf --- /dev/null +++ b/spec/ruby_llm/providers/minimax/models_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::MiniMax::Models do + include_context 'with configured RubyLLM' + + let(:provider) { RubyLLM::Providers::MiniMax.new(RubyLLM.config) } + + describe '#list_models' do + it 'returns statically defined MiniMax models' do + models = provider.list_models + model_ids = models.map(&:id) + + expect(model_ids).to include('MiniMax-M2.7', 'MiniMax-M2.7-highspeed', + 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed') + end + + it 'returns 4 models' do + expect(provider.list_models.size).to eq(4) + end + + it 'sets the provider slug to minimax' do + models = provider.list_models + expect(models).to all(have_attributes(provider: 'minimax')) + end + + it 'sets correct context window for M2.7' do + models = provider.list_models + m27 = models.find { |m| m.id == 'MiniMax-M2.7' } + expect(m27.context_window).to eq(1_000_000) + end + + it 'sets correct context window for M2.5-highspeed' do + models = provider.list_models + m25hs = models.find { |m| m.id == 'MiniMax-M2.5-highspeed' } + expect(m25hs.context_window).to eq(204_000) + end + end +end diff --git a/spec/ruby_llm/providers/minimax/temperature_spec.rb b/spec/ruby_llm/providers/minimax/temperature_spec.rb new file mode 100644 index 000000000..533708a48 --- /dev/null +++ b/spec/ruby_llm/providers/minimax/temperature_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::MiniMax::Temperature do + describe '.normalize' do + it 'returns nil when temperature is nil' do + expect(described_class.normalize(nil)).to be_nil + end + + it 'passes through valid temperature values' do + expect(described_class.normalize(0.5)).to eq(0.5) + end + + it 'accepts temperature 0.0' do + expect(described_class.normalize(0.0)).to eq(0.0) + end + + it 'accepts temperature 1.0' do + expect(described_class.normalize(1.0)).to eq(1.0) + end + + it 'clamps temperature above 1.0 to 1.0' do + expect(described_class.normalize(1.5)).to eq(1.0) + end + + it 'clamps temperature below 0.0 to 0.0' do + expect(described_class.normalize(-0.5)).to eq(0.0) + end + + it 'clamps temperature 2.0 to 1.0' do + expect(described_class.normalize(2.0)).to eq(1.0) + end + end +end diff --git a/spec/ruby_llm/providers/minimax_spec.rb b/spec/ruby_llm/providers/minimax_spec.rb new file mode 100644 index 000000000..f8fed7088 --- /dev/null +++ b/spec/ruby_llm/providers/minimax_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::MiniMax do + subject(:provider) { described_class.new(config) } + + let(:config) do + instance_double( + RubyLLM::Configuration, + request_timeout: 300, + max_retries: 3, + retry_interval: 0.1, + retry_interval_randomness: 0.5, + retry_backoff_factor: 2, + http_proxy: nil, + minimax_api_key: 'test-key', + minimax_api_base: minimax_api_base + ) + end + + describe '#api_base' do + context 'when minimax_api_base is not set' do + let(:minimax_api_base) { nil } + + it 'returns the default MiniMax API URL' do + expect(provider.api_base).to eq('https://api.minimax.io/v1') + end + end + + context 'when minimax_api_base is set' do + let(:minimax_api_base) { 'https://custom-minimax-endpoint.example.com' } + + it 'returns the custom API URL' do + expect(provider.api_base).to eq('https://custom-minimax-endpoint.example.com') + end + end + end + + describe '#headers' do + let(:minimax_api_base) { nil } + + it 'returns Authorization header with API key' do + expect(provider.headers).to include('Authorization' => 'Bearer test-key') + end + + it 'returns Content-Type header' do + expect(provider.headers).to include('Content-Type' => 'application/json') + end + end + + describe '.configuration_options' do + it 'includes minimax_api_key and minimax_api_base' do + expect(described_class.configuration_options).to contain_exactly(:minimax_api_key, :minimax_api_base) + end + end + + describe '.configuration_requirements' do + it 'requires minimax_api_key' do + expect(described_class.configuration_requirements).to contain_exactly(:minimax_api_key) + end + end + + describe '.capabilities' do + it 'returns MiniMax::Capabilities module' do + expect(described_class.capabilities).to eq(RubyLLM::Providers::MiniMax::Capabilities) + end + end + + describe '.slug' do + it 'returns minimax' do + expect(described_class.slug).to eq('minimax') + end + end +end diff --git a/spec/support/models_to_test.rb b/spec/support/models_to_test.rb index 29e90a007..d89b099ee 100644 --- a/spec/support/models_to_test.rb +++ b/spec/support/models_to_test.rb @@ -14,6 +14,7 @@ def filter_local_providers(models) { provider: :deepseek, model: 'deepseek-chat' }, { provider: :gemini, model: 'gemini-2.5-flash' }, { provider: :gpustack, model: 'qwen3' }, + { provider: :minimax, model: 'MiniMax-M2.7' }, { provider: :mistral, model: 'mistral-small-latest' }, { provider: :ollama, model: 'qwen3' }, { provider: :openai, model: 'gpt-5-nano' }, diff --git a/spec/support/rubyllm_configuration.rb b/spec/support/rubyllm_configuration.rb index 6b3cede88..fcff717d3 100644 --- a/spec/support/rubyllm_configuration.rb +++ b/spec/support/rubyllm_configuration.rb @@ -25,6 +25,7 @@ config.gpustack_api_key = ENV.fetch('GPUSTACK_API_KEY', nil) # Disable retries in tests for deterministic, fast failures. config.max_retries = 0 + config.minimax_api_key = ENV.fetch('MINIMAX_API_KEY', 'test') config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', 'test') config.model_registry_class = 'Model' config.ollama_api_base = ENV.fetch('OLLAMA_API_BASE', 'http://localhost:11434/v1')