diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb
index 3e5c17a3c..45ca0e77d 100644
--- a/lib/ruby_llm.rb
+++ b/lib/ruby_llm.rb
@@ -23,6 +23,7 @@
'openrouter' => 'OpenRouter',
'gpustack' => 'GPUStack',
'mistral' => 'Mistral',
+ 'togetherai' => 'TogetherAI',
'vertexai' => 'VertexAI',
'pdf' => 'PDF',
'UI' => 'UI'
@@ -100,6 +101,7 @@ def logger
RubyLLM::Provider.register :openrouter, RubyLLM::Providers::OpenRouter
RubyLLM::Provider.register :perplexity, RubyLLM::Providers::Perplexity
RubyLLM::Provider.register :vertexai, RubyLLM::Providers::VertexAI
+RubyLLM::Provider.register :togetherai, RubyLLM::Providers::TogetherAI
if defined?(Rails::Railtie)
require 'ruby_llm/railtie'
diff --git a/lib/ruby_llm/configuration.rb b/lib/ruby_llm/configuration.rb
index e1c12902a..25849bd46 100644
--- a/lib/ruby_llm/configuration.rb
+++ b/lib/ruby_llm/configuration.rb
@@ -24,6 +24,7 @@ class Configuration
:gpustack_api_base,
:gpustack_api_key,
:mistral_api_key,
+ :togetherai_api_key,
# Default models
:default_model,
:default_embedding_model,
diff --git a/lib/ruby_llm/providers/togetherai.rb b/lib/ruby_llm/providers/togetherai.rb
new file mode 100644
index 000000000..cc6b366be
--- /dev/null
+++ b/lib/ruby_llm/providers/togetherai.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Providers
+ # Together.ai API integration.
+ class TogetherAI < Provider
+ include TogetherAI::Chat
+ include TogetherAI::Models
+
+ def api_base
+ 'https://api.together.xyz/v1'
+ end
+
+ def headers
+ headers_hash = { 'Content-Type' => 'application/json' }
+
+ if @config.togetherai_api_key && !@config.togetherai_api_key.empty?
+ headers_hash['Authorization'] = "Bearer #{@config.togetherai_api_key}"
+ end
+
+ headers_hash
+ end
+
+ class << self
+ def capabilities
+ TogetherAI::Capabilities
+ end
+
+ def configuration_requirements
+ %i[togetherai_api_key]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ruby_llm/providers/togetherai/capabilities.rb b/lib/ruby_llm/providers/togetherai/capabilities.rb
new file mode 100644
index 000000000..4ae3de43a
--- /dev/null
+++ b/lib/ruby_llm/providers/togetherai/capabilities.rb
@@ -0,0 +1,273 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Providers
+ class TogetherAI
+ # Capabilities for the Together.ai provider
+ module Capabilities
+ def self.supports_streaming?(model_id)
+ # Most chat models support streaming, exclude specialized non-chat models
+ supports_chat_for?(model_id)
+ end
+
+ def self.supports_vision?(model_id)
+ supports_vision_for?(model_id)
+ end
+
+ def self.supports_functions?(model_id)
+ supports_tools_for?(model_id)
+ end
+
+ def self.supports_json_mode?(model_id)
+ # Most chat models support JSON mode, exclude specialized models
+ supports_chat_for?(model_id) && !model_id.match?(/whisper|voxtral/i)
+ end
+
+ def self.model_type(model_id)
+ return 'embedding' if supports_embeddings_for?(model_id)
+ return 'image' if supports_images_for?(model_id)
+ return 'audio' if supports_audio_for?(model_id)
+ return 'moderation' if supports_moderation_for?(model_id)
+
+ 'chat'
+ end
+
+ def self.normalize_temperature(temperature, _model)
+ # Together.ai accepts temperature values between 0.0 and 2.0
+ return temperature if temperature.nil?
+
+ temperature.clamp(0.0, 2.0)
+ end
+
+ def self.max_tokens_for_model(_model)
+ # Default max tokens for Together.ai models
+ # This would ideally be model-specific
+ 4096
+ end
+
+ def self.format_display_name(model_id)
+ model_id.split('/').last.tr('-', ' ').titleize
+ end
+
+ def self.model_family(model_id)
+ case model_id
+ when /llama/i then 'llama'
+ when /qwen/i then 'qwen'
+ when /mistral/i then 'mistral'
+ when /deepseek/i then 'deepseek'
+ when /gemma/i then 'gemma'
+ when /moonshot/i then 'kimi'
+ when /glm/i then 'glm'
+ when /cogito/i then 'cogito'
+ when /arcee/i then 'arcee'
+ when /marin/i then 'marin'
+ when /gryphe/i then 'mythomax'
+ when /openai/i then 'openai'
+ else 'other'
+ end
+ end
+
+ def self.context_window_for(model_id)
+ # Context windows based on Together.ai model specifications
+ # Using a hash lookup for better performance and maintainability
+ context_windows = {
+ # Ultra large context (1M+ tokens)
+ 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8' => 524_288,
+ 'meta-llama/Llama-4-Scout-17B-16E-Instruct' => 327_680,
+
+ # 256K+ context models
+ 'moonshotai/Kimi-K2-Instruct-0905' => 262_144,
+ 'moonshotai/Kimi-K2-Thinking' => 262_144,
+ 'Qwen/Qwen3-235B-A22B-Thinking-2507' => 262_144,
+ 'Qwen/Qwen3-235B-A22B-Instruct-2507-tput' => 262_144,
+ 'Qwen/Qwen3-Next-80B-A3B-Instruct' => 262_144,
+ 'Qwen/Qwen3-Next-80B-A3B-Thinking' => 262_144,
+ 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8' => 256_000,
+
+ # ~200K context models
+ 'zai-org/GLM-4.6' => 202_752,
+
+ # ~160K context models
+ 'deepseek-ai/DeepSeek-R1' => 163_839,
+ 'deepseek-ai/DeepSeek-R1-0528-tput' => 163_839,
+ 'deepseek-ai/DeepSeek-V3' => 163_839,
+
+ # ~130K context models
+ 'meta-llama/Llama-3.3-70B-Instruct-Turbo' => 131_072,
+ 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo' => 131_072,
+ 'meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo' => 130_815,
+ 'meta-llama/Llama-3.2-3B-Instruct-Turbo' => 131_072,
+ 'deepseek-ai/DeepSeek-R1-Distill-Llama-70B' => 131_072,
+ 'deepseek-ai/DeepSeek-R1-Distill-Qwen-14B' => 131_072,
+ 'zai-org/GLM-4.5-Air-FP8' => 131_072,
+
+ # ~128K context models
+ 'moonshotai/Kimi-K2-Instruct' => 128_000,
+ 'deepseek-ai/DeepSeek-V3.1' => 128_000,
+ 'openai/gpt-oss-120b' => 128_000,
+ 'openai/gpt-oss-20b' => 128_000,
+ 'arcee-ai/virtuoso-medium-v2' => 128_000,
+ 'arcee-ai/virtuoso-large' => 128_000,
+ 'arcee-ai/maestro-reasoning' => 128_000,
+ 'arcee_ai/arcee-spotlight' => 128_000,
+
+ # ~40K context models
+ 'Qwen/Qwen3-235B-A22B-fp8-tput' => 40_960,
+ 'mistralai/Magistral-Small-2506' => 40_960,
+
+ # ~32K context models (most common)
+ 'Qwen/Qwen2.5-7B-Instruct-Turbo' => 32_768,
+ 'Qwen/Qwen2.5-72B-Instruct-Turbo' => 32_768,
+ 'Qwen/Qwen2.5-VL-72B-Instruct' => 32_768,
+ 'Qwen/Qwen2.5-Coder-32B-Instruct' => 32_768,
+ 'Qwen/QwQ-32B' => 32_768,
+ 'mistralai/Mistral-Small-24B-Instruct-2501' => 32_768,
+ 'mistralai/Mistral-7B-Instruct-v0.2' => 32_768,
+ 'mistralai/Mistral-7B-Instruct-v0.3' => 32_768,
+ 'google/gemma-3n-E4B-it' => 32_768,
+ 'arcee-ai/coder-large' => 32_768,
+ 'arcee-ai/caller' => 32_768,
+ 'arcee-ai/arcee-blitz' => 32_768,
+
+ # ~8K context models
+ 'meta-llama/Llama-3.3-70B-Instruct-Turbo-Free' => 8_193,
+ 'meta-llama/Meta-Llama-3-8B-Instruct-Lite' => 8_192,
+ 'meta-llama/Llama-3-70b-chat-hf' => 8_192,
+ 'mistralai/Mistral-7B-Instruct-v0.1' => 8_192,
+ 'google/gemma-2b-it' => 8_192,
+
+ # ~4K context models
+ 'marin-community/marin-8b-instruct' => 4_096,
+ 'Gryphe/MythoMax-L2-13b' => 4_096
+ }
+
+ # Check for exact match first
+ return context_windows[model_id] if context_windows.key?(model_id)
+
+ # Pattern matching for model families
+ case model_id
+ when %r{^deepcogito/cogito-v2.*} then 32_768
+ when %r{^Qwen/Qwen3.*235B.*} then 262_144
+ when %r{^meta-llama/Llama-4.*} then 1_048_576
+ else 16_384 # Default context window for unknown models
+ end
+ end
+
+ def self.max_tokens_for(model_id)
+ max_tokens_for_model(model_id)
+ end
+
+ def self.modalities_for(model_id)
+ input_modalities = ['text']
+ output_modalities = ['text']
+
+ input_modalities << 'image' if supports_vision_for?(model_id)
+ input_modalities << 'audio' if supports_audio_for?(model_id) && !model_id.match?(/sonic/i)
+
+ output_modalities = ['image'] if supports_images_for?(model_id)
+ output_modalities << 'audio' if model_id.match?(/sonic|voxtral/i)
+
+ { input: input_modalities, output: output_modalities }
+ end
+
+ def self.capabilities_for(model_id)
+ capabilities = primary_capabilities(model_id)
+ capabilities.concat(chat_capabilities(model_id)) if supports_chat_for?(model_id)
+ capabilities.concat(specialized_capabilities(model_id))
+ capabilities
+ end
+
+ def self.primary_capabilities(model_id)
+ [].tap do |caps|
+ caps << 'chat' if supports_chat_for?(model_id)
+ caps << 'embeddings' if supports_embeddings_for?(model_id)
+ caps << 'images' if supports_images_for?(model_id)
+ end
+ end
+
+ def self.chat_capabilities(model_id)
+ [].tap do |caps|
+ caps << 'streaming' if supports_streaming?(model_id)
+ caps << 'tools' if supports_tools_for?(model_id)
+ caps << 'json_mode' if supports_json_mode?(model_id)
+ caps << 'vision' if supports_vision_for?(model_id)
+ end
+ end
+
+ def self.specialized_capabilities(model_id)
+ [].tap do |caps|
+ caps << 'transcription' if supports_audio_for?(model_id)
+ caps << 'moderation' if supports_moderation_for?(model_id)
+ end
+ end
+
+ def self.supports_tools_for?(model_id)
+ # Most chat models support function calling, exclude non-chat models
+ return false if supports_embeddings_for?(model_id)
+ return false if supports_images_for?(model_id)
+ return false if supports_audio_for?(model_id)
+ return false if supports_moderation_for?(model_id)
+
+ true
+ end
+
+ def self.supports_chat_for?(model_id)
+ # Chat models are the main category, exclude non-chat models
+ return false if supports_embeddings_for?(model_id)
+ return false if supports_images_for?(model_id)
+ return false if supports_audio_for?(model_id) && !supports_vision_for?(model_id)
+ return false if supports_moderation_for?(model_id) && !model_id.match?(/Llama-Guard/i)
+
+ true
+ end
+
+ def self.supports_embeddings_for?(model_id)
+ # Embedding models
+ model_id.match?(/bge-|m2-bert|gte-|multilingual-e5/i)
+ end
+
+ # Methods for detecting different model capabilities
+ def self.supports_images_for?(model_id)
+ # Image generation models (FLUX, Stable Diffusion, Imagen)
+ model_id.match?(/FLUX|stable-diffusion|imagen/i)
+ end
+
+ def self.supports_vision_for?(model_id)
+ # Vision models (multimodal models that can process images)
+ model_id.match?(/Scout|VL|spotlight/i)
+ end
+
+ def self.supports_video_for?(_model_id)
+ false # Video generation support will be added in future PR
+ end
+
+ def self.supports_audio_for?(model_id)
+ # Audio models (TTS and transcription)
+ model_id.match?(/sonic|whisper|voxtral|orpheus/i)
+ end
+
+ def self.supports_transcription_for?(model_id)
+ # Transcription-specific models
+ model_id.match?(/whisper/i)
+ end
+
+ def self.supports_moderation_for?(model_id)
+ # Moderation models
+ model_id.match?(/Guard|VirtueGuard/i)
+ end
+
+ def self.supports_rerank_for?(_model_id)
+ false # Rerank support will be added in future PR
+ end
+
+ def self.pricing_for(_model_id)
+ # Placeholder pricing - should be model-specific
+ {
+ input_tokens: 0.001,
+ output_tokens: 0.002
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ruby_llm/providers/togetherai/chat.rb b/lib/ruby_llm/providers/togetherai/chat.rb
new file mode 100644
index 000000000..0e1776435
--- /dev/null
+++ b/lib/ruby_llm/providers/togetherai/chat.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Providers
+ class TogetherAI
+ # Chat methods for the Together.ai provider
+ module Chat
+ def completion_url
+ 'chat/completions'
+ end
+
+ module_function
+
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
+ payload = {
+ model: model.id,
+ messages: format_messages(messages),
+ stream: stream
+ }
+
+ payload[:temperature] = temperature unless temperature.nil?
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
+
+ # Together.ai supports structured output via response_format
+ if schema
+ payload[:response_format] = {
+ type: 'json_schema',
+ json_schema: {
+ name: 'response',
+ schema: schema
+ }
+ }
+ end
+
+ payload[:stream_options] = { include_usage: true } if stream
+ payload
+ end
+
+ def parse_completion_response(response)
+ data = response.body
+ return if data.empty?
+
+ raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
+
+ message_data = data.dig('choices', 0, 'message')
+ return unless message_data
+
+ usage = data['usage'] || {}
+
+ Message.new(
+ role: :assistant,
+ content: message_data['content'],
+ tool_calls: parse_tool_calls(message_data['tool_calls']),
+ input_tokens: usage['prompt_tokens'],
+ output_tokens: usage['completion_tokens'],
+ cached_tokens: 0,
+ cache_creation_tokens: 0,
+ model_id: data['model'],
+ raw: response
+ )
+ end
+
+ def format_messages(messages)
+ messages.map do |msg|
+ {
+ role: msg.role.to_s,
+ content: format_content(msg.content),
+ tool_calls: format_tool_calls(msg.tool_calls),
+ tool_call_id: msg.tool_call_id
+ }.compact
+ end
+ end
+
+ def format_content(content)
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
+ return content unless content.is_a?(Content)
+
+ # Together.ai expects simple string content for text-only messages
+ parts = []
+ parts << content.text if content.text
+
+ content.attachments.each do |attachment|
+ case attachment.type
+ when :text
+ # Include text file content inline
+ parts << format_text_file(attachment)
+ else
+ # Together.ai doesn't support other attachment types in the standard chat API
+ raise UnsupportedAttachmentError, attachment.type
+ end
+ end
+
+ parts.join("\n")
+ end
+
+ def format_text_file(attachment)
+ "#{attachment.content}"
+ end
+
+ def format_tool_calls(tool_calls)
+ return unless tool_calls&.any?
+
+ tool_calls.map do |tool_call|
+ {
+ id: tool_call.id,
+ type: 'function',
+ function: {
+ name: tool_call.name,
+ arguments: tool_call.arguments.to_json
+ }
+ }
+ end
+ end
+
+ def parse_tool_calls(tool_calls_data)
+ return [] unless tool_calls_data&.any?
+
+ tool_calls_data.map do |tool_call|
+ ToolCall.new(
+ id: tool_call['id'],
+ name: tool_call.dig('function', 'name'),
+ arguments: JSON.parse(tool_call.dig('function', 'arguments') || '{}')
+ )
+ rescue JSON::ParserError
+ ToolCall.new(
+ id: tool_call['id'],
+ name: tool_call.dig('function', 'name'),
+ arguments: {}
+ )
+ end
+ end
+
+ def tool_for(tool)
+ {
+ type: 'function',
+ function: {
+ name: tool.name,
+ description: tool.description,
+ parameters: tool.parameters
+ }
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ruby_llm/providers/togetherai/models.rb b/lib/ruby_llm/providers/togetherai/models.rb
new file mode 100644
index 000000000..aa0db80d9
--- /dev/null
+++ b/lib/ruby_llm/providers/togetherai/models.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Providers
+ class TogetherAI
+ # Models methods for the Together.ai provider
+ module Models
+ module_function
+
+ def models_url
+ 'models'
+ end
+
+ def parse_list_models_response(response, slug, capabilities)
+ return [] unless response&.body
+
+ # TogetherAI returns models as an array directly
+ models_array = response.body.is_a?(Array) ? response.body : []
+
+ models_array.filter_map do |model_data|
+ build_model_info(model_data, slug, capabilities)
+ end
+ end
+
+ def build_model_info(model_data, slug, capabilities)
+ model_id = model_data['id']
+ return unless model_id
+
+ created_at = parse_created_at(model_data['created'])
+
+ Model::Info.new(
+ id: model_id,
+ name: model_data['display_name'] || capabilities.format_display_name(model_id),
+ provider: slug,
+ family: capabilities.model_family(model_id),
+ created_at: created_at,
+ context_window: model_data['context_length'] || 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: build_metadata(model_data)
+ )
+ end
+
+ def parse_created_at(created_timestamp)
+ return unless created_timestamp&.positive?
+
+ Time.at(created_timestamp)
+ end
+
+ def build_metadata(model_data)
+ {
+ object: model_data['object'],
+ owned_by: model_data['owned_by'],
+ type: model_data['type'],
+ organization: model_data['organization'],
+ license: model_data['license'],
+ link: model_data['link']
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/models.rake b/lib/tasks/models.rake
index 135dbcb7d..6a5c5ac11 100644
--- a/lib/tasks/models.rake
+++ b/lib/tasks/models.rake
@@ -46,6 +46,7 @@ def configure_from_env
config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil)
config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil)
config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil)
+ config.togetherai_api_key = ENV.fetch('TOGETHERAI_API_KEY', nil)
config.vertexai_location = ENV.fetch('GOOGLE_CLOUD_LOCATION', nil)
config.vertexai_project_id = ENV.fetch('GOOGLE_CLOUD_PROJECT', nil)
configure_bedrock(config)
diff --git a/spec/ruby_llm/providers/together_ai/capabilities_spec.rb b/spec/ruby_llm/providers/together_ai/capabilities_spec.rb
new file mode 100644
index 000000000..d143fd445
--- /dev/null
+++ b/spec/ruby_llm/providers/together_ai/capabilities_spec.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RubyLLM::Providers::TogetherAI::Capabilities do
+ describe '.supports_streaming?' do
+ it 'returns true for chat models' do
+ expect(described_class.supports_streaming?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be true
+ end
+
+ it 'returns false for non-chat models' do
+ expect(described_class.supports_streaming?('BAAI/bge-large-en-v1.5')).to be false
+ end
+ end
+
+ describe '.supports_vision?' do
+ it 'returns true for vision models' do
+ expect(described_class.supports_vision?('meta-llama/Llama-4-Scout-17B-16E-Instruct')).to be true
+ end
+
+ it 'returns false for non-vision models' do
+ expect(described_class.supports_vision?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be false
+ end
+ end
+
+ describe '.supports_functions?' do
+ it 'returns true for chat models' do
+ expect(described_class.supports_functions?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be true
+ end
+
+ it 'returns false for non-tool models' do
+ expect(described_class.supports_functions?('BAAI/bge-large-en-v1.5')).to be false
+ end
+ end
+
+ describe '.supports_json_mode?' do
+ it 'returns true for chat models' do
+ expect(described_class.supports_json_mode?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be true
+ end
+
+ it 'returns false for non-chat models' do
+ expect(described_class.supports_json_mode?('BAAI/bge-large-en-v1.5')).to be false
+ end
+
+ it 'returns false for audio models' do
+ expect(described_class.supports_json_mode?('openai/whisper-large-v3')).to be false
+ end
+ end
+
+ describe '.normalize_temperature' do
+ it 'returns nil when temperature is nil' do
+ expect(described_class.normalize_temperature(nil, 'model')).to be_nil
+ end
+
+ it 'clamps temperature to minimum 0.0' do
+ expect(described_class.normalize_temperature(-0.5, 'model')).to eq(0.0)
+ end
+
+ it 'clamps temperature to maximum 2.0' do
+ expect(described_class.normalize_temperature(2.5, 'model')).to eq(2.0)
+ end
+
+ it 'preserves valid temperature values' do
+ expect(described_class.normalize_temperature(1.0, 'model')).to eq(1.0)
+ expect(described_class.normalize_temperature(0.7, 'model')).to eq(0.7)
+ end
+ end
+
+ describe '.format_display_name' do
+ it 'formats model ID to display name' do
+ expect(described_class.format_display_name('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo'))
+ .to eq('Meta Llama 3.1 8 B Instruct Turbo')
+ expect(described_class.format_display_name('Qwen/Qwen2.5-72B-Instruct-Turbo'))
+ .to eq('Qwen2.5 72 B Instruct Turbo')
+ end
+ end
+
+ describe '.model_family' do
+ it 'identifies Llama models' do
+ expect(described_class.model_family('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to eq('llama')
+ end
+
+ it 'identifies Qwen models' do
+ expect(described_class.model_family('Qwen/Qwen2.5-72B-Instruct-Turbo')).to eq('qwen')
+ end
+
+ it 'identifies Mistral models' do
+ expect(described_class.model_family('mistralai/Mistral-7B-Instruct-v0.3')).to eq('mistral')
+ end
+
+ it 'identifies DeepSeek models' do
+ expect(described_class.model_family('deepseek-ai/deepseek-v3')).to eq('deepseek')
+ end
+
+ it 'defaults to other for unknown models' do
+ expect(described_class.model_family('unknown/model')).to eq('other')
+ end
+ end
+
+ describe '.context_window_for' do
+ it 'returns appropriate context windows for different model sizes' do
+ expect(described_class.context_window_for('meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo')).to eq(130_815)
+ expect(described_class.context_window_for('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to eq(131_072)
+ expect(described_class.context_window_for('Qwen/Qwen2.5-72B-Instruct-Turbo')).to eq(32_768)
+ expect(described_class.context_window_for('moonshotai/Kimi-K2-Instruct-0905')).to eq(262_144)
+ expect(described_class.context_window_for('deepseek-ai/DeepSeek-V3.1')).to eq(128_000)
+ expect(described_class.context_window_for('unknown/model')).to eq(16_384)
+ end
+ end
+
+ describe '.supports_tools_for?' do
+ it 'returns true for chat models' do
+ expect(described_class.supports_tools_for?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be true
+ expect(described_class.supports_tools_for?('Qwen/Qwen2.5-72B-Instruct-Turbo')).to be true
+ expect(described_class.supports_tools_for?('deepseek-ai/DeepSeek-V3.1')).to be true
+ end
+
+ it 'returns false for non-chat models' do
+ expect(described_class.supports_tools_for?('BAAI/bge-large-en-v1.5')).to be false
+ expect(described_class.supports_tools_for?('black-forest-labs/FLUX.1-schnell')).to be false
+ expect(described_class.supports_tools_for?('openai/whisper-large-v3')).to be false
+ expect(described_class.supports_tools_for?('cartesia/sonic-2')).to be false
+ end
+ end
+
+ describe '.supports_embeddings_for?' do
+ it 'returns true for embedding models' do
+ expect(described_class.supports_embeddings_for?('BAAI/bge-large-en-v1.5')).to be true
+ expect(described_class.supports_embeddings_for?('togethercomputer/m2-bert-80M-32k-retrieval')).to be true
+ end
+
+ it 'returns false for non-embedding models' do
+ expect(described_class.supports_embeddings_for?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be false
+ end
+ end
+
+ describe '.supports_images_for?' do
+ it 'returns true for image generation models' do
+ expect(described_class.supports_images_for?('black-forest-labs/FLUX.1-schnell')).to be true
+ expect(described_class.supports_images_for?('google/imagen-4.0-preview')).to be true
+ expect(described_class.supports_images_for?('stabilityai/stable-diffusion-xl-base-1.0')).to be true
+ end
+
+ it 'returns false for non-image models' do
+ expect(described_class.supports_images_for?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be false
+ end
+ end
+
+ describe '.supports_vision_for?' do
+ it 'returns true for vision models' do
+ expect(described_class.supports_vision_for?('meta-llama/Llama-4-Scout-17B-16E-Instruct')).to be true
+ expect(described_class.supports_vision_for?('Qwen/Qwen2.5-VL-72B-Instruct')).to be true
+ expect(described_class.supports_vision_for?('arcee_ai/arcee-spotlight')).to be true
+ end
+
+ it 'returns false for non-vision models' do
+ expect(described_class.supports_vision_for?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be false
+ end
+ end
+
+ describe '.supports_audio_for?' do
+ it 'returns true for audio models' do
+ expect(described_class.supports_audio_for?('cartesia/sonic-2')).to be true
+ expect(described_class.supports_audio_for?('openai/whisper-large-v3')).to be true
+ expect(described_class.supports_audio_for?('canopylabs/orpheus-3b-0.1-ft')).to be true
+ end
+
+ it 'returns false for non-audio models' do
+ expect(described_class.supports_audio_for?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be false
+ end
+ end
+
+ describe '.supports_moderation_for?' do
+ it 'returns true for moderation models' do
+ expect(described_class.supports_moderation_for?('meta-llama/Llama-Guard-4-12B')).to be true
+ expect(described_class.supports_moderation_for?('VirtueAI/VirtueGuard-Text-Lite')).to be true
+ end
+
+ it 'returns false for non-moderation models' do
+ expect(described_class.supports_moderation_for?('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to be false
+ end
+ end
+
+ describe '.modalities_for' do
+ it 'returns text input/output for chat models' do
+ result = described_class.modalities_for('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ expect(result[:input]).to eq(['text'])
+ expect(result[:output]).to eq(['text'])
+ end
+
+ it 'includes image input for vision models' do
+ result = described_class.modalities_for('meta-llama/Llama-4-Scout-17B-16E-Instruct')
+ expect(result[:input]).to include('image')
+ end
+
+ it 'includes image output for image generation models' do
+ result = described_class.modalities_for('black-forest-labs/FLUX.1-schnell')
+ expect(result[:output]).to include('image')
+ end
+
+ it 'includes audio input for transcription models' do
+ result = described_class.modalities_for('openai/whisper-large-v3')
+ expect(result[:input]).to include('audio')
+ end
+
+ it 'includes audio output for TTS models' do
+ result = described_class.modalities_for('cartesia/sonic-2')
+ expect(result[:output]).to include('audio')
+ end
+ end
+
+ describe '.capabilities_for' do
+ it 'returns appropriate capabilities for chat models' do
+ result = described_class.capabilities_for('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ expect(result).to include('chat', 'streaming', 'tools', 'json_mode')
+ expect(result).not_to include('embeddings', 'images')
+ end
+
+ it 'returns appropriate capabilities for embedding models' do
+ result = described_class.capabilities_for('BAAI/bge-large-en-v1.5')
+ expect(result).to include('embeddings')
+ expect(result).not_to include('chat', 'tools', 'streaming')
+ end
+
+ it 'returns appropriate capabilities for image models' do
+ result = described_class.capabilities_for('black-forest-labs/FLUX.1-schnell')
+ expect(result).to include('images')
+ expect(result).not_to include('chat', 'embeddings', 'streaming')
+ end
+
+ it 'returns appropriate capabilities for vision models' do
+ result = described_class.capabilities_for('meta-llama/Llama-4-Scout-17B-16E-Instruct')
+ expect(result).to include('chat', 'streaming', 'tools', 'json_mode', 'vision')
+ end
+ end
+
+ describe '.model_type' do
+ it 'returns chat for chat models' do
+ expect(described_class.model_type('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')).to eq('chat')
+ end
+
+ it 'returns embedding for embedding models' do
+ expect(described_class.model_type('BAAI/bge-large-en-v1.5')).to eq('embedding')
+ end
+
+ it 'returns image for image generation models' do
+ expect(described_class.model_type('black-forest-labs/FLUX.1-schnell')).to eq('image')
+ end
+
+ it 'returns audio for audio models' do
+ expect(described_class.model_type('cartesia/sonic-2')).to eq('audio')
+ end
+
+ it 'returns moderation for moderation models' do
+ expect(described_class.model_type('meta-llama/Llama-Guard-4-12B')).to eq('moderation')
+ end
+ end
+end
diff --git a/spec/ruby_llm/providers/together_ai/chat_spec.rb b/spec/ruby_llm/providers/together_ai/chat_spec.rb
new file mode 100644
index 000000000..e59c4ffae
--- /dev/null
+++ b/spec/ruby_llm/providers/together_ai/chat_spec.rb
@@ -0,0 +1,256 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RubyLLM::Providers::TogetherAI::Chat do
+ describe '.render_payload' do
+ let(:messages) { [instance_double(RubyLLM::Message, role: :user, content: 'Hello')] }
+ let(:model) { instance_double(RubyLLM::Model::Info, id: 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo') }
+
+ before do
+ allow(described_class).to receive(:format_messages).and_return([{ role: 'user', content: 'Hello' }])
+ end
+
+ it 'creates a basic payload' do
+ payload = described_class.render_payload(
+ messages,
+ tools: {},
+ temperature: nil,
+ model: model,
+ stream: false
+ )
+
+ expect(payload).to include(
+ model: 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
+ messages: [{ role: 'user', content: 'Hello' }],
+ stream: false
+ )
+ end
+
+ it 'includes temperature when provided' do
+ payload = described_class.render_payload(
+ messages,
+ tools: {},
+ temperature: 0.7,
+ model: model,
+ stream: false
+ )
+
+ expect(payload[:temperature]).to eq(0.7)
+ end
+
+ it 'includes tools when provided' do
+ tool = instance_double(RubyLLM::Tool, name: 'test_function', description: 'Test', parameters: {})
+ tools = { 'test_function' => tool }
+
+ allow(described_class).to receive(:tool_for).with(tool).and_return({
+ type: 'function',
+ function: { name: 'test_function',
+ description: 'Test',
+ parameters: {} }
+ })
+
+ payload = described_class.render_payload(
+ messages,
+ tools: tools,
+ temperature: nil,
+ model: model,
+ stream: false
+ )
+
+ expect(payload[:tools]).to eq([{
+ type: 'function',
+ function: { name: 'test_function', description: 'Test', parameters: {} }
+ }])
+ end
+
+ it 'includes JSON schema for structured output' do
+ schema = { type: 'object', properties: { answer: { type: 'string' } } }
+
+ payload = described_class.render_payload(
+ messages,
+ tools: {},
+ temperature: nil,
+ model: model,
+ stream: false,
+ schema: schema
+ )
+
+ expect(payload[:response_format]).to eq({
+ type: 'json_schema',
+ json_schema: {
+ name: 'response',
+ schema: schema
+ }
+ })
+ end
+
+ it 'includes stream options for streaming' do
+ payload = described_class.render_payload(
+ messages,
+ tools: {},
+ temperature: nil,
+ model: model,
+ stream: true
+ )
+
+ expect(payload[:stream_options]).to eq({ include_usage: true })
+ end
+ end
+
+ describe '.parse_completion_response' do
+ it 'parses a successful response' do
+ response_body = {
+ 'model' => 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
+ 'choices' => [
+ {
+ 'message' => {
+ 'role' => 'assistant',
+ 'content' => 'Hello! How can I help you today?'
+ }
+ }
+ ],
+ 'usage' => {
+ 'prompt_tokens' => 10,
+ 'completion_tokens' => 8
+ }
+ }
+
+ response = instance_double(Faraday::Response, body: response_body)
+ allow(described_class).to receive(:parse_tool_calls).and_return([])
+
+ message = described_class.parse_completion_response(response)
+
+ expect(message.role).to eq(:assistant)
+ expect(message.content).to eq('Hello! How can I help you today?')
+ expect(message.input_tokens).to eq(10)
+ expect(message.output_tokens).to eq(8)
+ expect(message.model_id).to eq('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ end
+
+ it 'handles empty response body' do
+ response = instance_double(Faraday::Response, body: {})
+
+ result = described_class.parse_completion_response(response)
+
+ expect(result).to be_nil
+ end
+
+ it 'raises error for API errors' do
+ response_body = {
+ 'error' => {
+ 'message' => 'Invalid API key'
+ }
+ }
+
+ response = instance_double(Faraday::Response, body: response_body)
+
+ expect do
+ described_class.parse_completion_response(response)
+ end.to raise_error(RubyLLM::Error, 'Invalid API key')
+ end
+
+ it 'handles tool calls in response' do
+ tool_call_data = {
+ 'id' => 'call_123',
+ 'function' => {
+ 'name' => 'get_weather',
+ 'arguments' => '{"location": "London"}'
+ }
+ }
+
+ response_body = {
+ 'model' => 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
+ 'choices' => [
+ {
+ 'message' => {
+ 'role' => 'assistant',
+ 'content' => nil,
+ 'tool_calls' => [tool_call_data]
+ }
+ }
+ ],
+ 'usage' => {
+ 'prompt_tokens' => 10,
+ 'completion_tokens' => 8
+ }
+ }
+
+ expected_tool_call = instance_double(RubyLLM::ToolCall)
+ response = instance_double(Faraday::Response, body: response_body)
+ allow(described_class).to receive(:parse_tool_calls).with([tool_call_data]).and_return([expected_tool_call])
+
+ message = described_class.parse_completion_response(response)
+
+ expect(message.tool_calls).to eq([expected_tool_call])
+ end
+ end
+
+ describe '.format_content' do
+ it 'returns string content as-is' do
+ result = described_class.format_content('Hello world')
+ expect(result).to eq('Hello world')
+ end
+
+ it 'returns array content as-is when not Content object' do
+ content = [
+ { 'type' => 'text', 'text' => 'Hello' },
+ { 'type' => 'image_url', 'image_url' => { 'url' => 'data:image/jpeg;base64,xyz' } },
+ { 'type' => 'text', 'text' => 'world' }
+ ]
+
+ result = described_class.format_content(content)
+ expect(result).to eq(content)
+ end
+
+ it 'returns non-Content objects as-is' do
+ result = described_class.format_content(123)
+ expect(result).to eq(123)
+ end
+ end
+
+ describe '.parse_tool_calls' do
+ it 'parses tool calls with valid JSON arguments' do
+ tool_calls_data = [
+ {
+ 'id' => 'call_123',
+ 'function' => {
+ 'name' => 'get_weather',
+ 'arguments' => '{"location": "London", "units": "metric"}'
+ }
+ }
+ ]
+
+ result = described_class.parse_tool_calls(tool_calls_data)
+
+ expect(result.length).to eq(1)
+ expect(result[0].id).to eq('call_123')
+ expect(result[0].name).to eq('get_weather')
+ expect(result[0].arguments).to eq({ 'location' => 'London', 'units' => 'metric' })
+ end
+
+ it 'handles invalid JSON arguments gracefully' do
+ tool_calls_data = [
+ {
+ 'id' => 'call_123',
+ 'function' => {
+ 'name' => 'get_weather',
+ 'arguments' => 'invalid json'
+ }
+ }
+ ]
+
+ result = described_class.parse_tool_calls(tool_calls_data)
+
+ expect(result.length).to eq(1)
+ expect(result[0].id).to eq('call_123')
+ expect(result[0].name).to eq('get_weather')
+ expect(result[0].arguments).to eq({})
+ end
+
+ it 'returns empty array for nil input' do
+ result = described_class.parse_tool_calls(nil)
+ expect(result).to eq([])
+ end
+ end
+end
diff --git a/spec/ruby_llm/providers/together_ai/models_spec.rb b/spec/ruby_llm/providers/together_ai/models_spec.rb
new file mode 100644
index 000000000..f2d913808
--- /dev/null
+++ b/spec/ruby_llm/providers/together_ai/models_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RubyLLM::Providers::TogetherAI::Models do
+ describe '.models_url' do
+ it 'returns the correct models endpoint' do
+ expect(described_class.models_url).to eq('models')
+ end
+ end
+
+ describe '.parse_list_models_response' do
+ let(:slug) { 'together' }
+ let(:capabilities) { RubyLLM::Providers::TogetherAI::Capabilities }
+
+ let(:response_body) do
+ [
+ {
+ 'id' => 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
+ 'object' => 'model',
+ 'created' => 1_234_567_890,
+ 'owned_by' => 'Meta'
+ },
+ {
+ 'id' => 'Qwen/Qwen2.5-72B-Instruct-Turbo',
+ 'object' => 'model',
+ 'created' => 1_234_567_891,
+ 'owned_by' => 'Alibaba'
+ }
+ ]
+ end
+
+ let(:response) { instance_double(Faraday::Response, body: response_body) }
+
+ before do
+ allow(capabilities).to receive_messages(format_display_name: 'Display Name', model_family: 'llama',
+ context_window_for: 8192, max_tokens_for: 4096,
+ modalities_for: { input: ['text'], output: ['text'] },
+ capabilities_for: ['chat'],
+ pricing_for: { input_tokens: 0.001, output_tokens: 0.002 })
+ end
+
+ it 'parses model information correctly' do
+ models = described_class.parse_list_models_response(response, slug, capabilities)
+
+ expect(models.length).to eq(2)
+
+ first_model = models.first
+ expect(first_model.id).to eq('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ expect(first_model.provider).to eq('together')
+ expect(first_model.created_at).to eq(Time.at(1_234_567_890))
+ expect(first_model.metadata[:object]).to eq('model')
+ expect(first_model.metadata[:owned_by]).to eq('Meta')
+ end
+
+ it 'handles empty response data' do
+ empty_response = instance_double(Faraday::Response, body: [])
+
+ models = described_class.parse_list_models_response(empty_response, slug, capabilities)
+
+ expect(models).to be_empty
+ end
+
+ it 'handles response with non-array body' do
+ no_data_response = instance_double(Faraday::Response, body: {})
+
+ models = described_class.parse_list_models_response(no_data_response, slug, capabilities)
+
+ expect(models).to be_empty
+ end
+
+ it 'calls capabilities methods with correct parameters' do
+ described_class.parse_list_models_response(response, slug, capabilities)
+
+ expect(capabilities).to have_received(:format_display_name).with('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ expect(capabilities).to have_received(:model_family).with('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ expect(capabilities).to have_received(:context_window_for).with('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ expect(capabilities).to have_received(:max_tokens_for).with('meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo')
+ end
+ end
+end
diff --git a/spec/ruby_llm/providers/together_ai_spec.rb b/spec/ruby_llm/providers/together_ai_spec.rb
new file mode 100644
index 000000000..e885cfb18
--- /dev/null
+++ b/spec/ruby_llm/providers/together_ai_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RubyLLM::Providers::TogetherAI do
+ let(:config) do
+ instance_double(
+ RubyLLM::Configuration,
+ togetherai_api_key: 'test-api-key',
+ request_timeout: 30,
+ max_retries: 3,
+ retry_interval: 1,
+ retry_backoff_factor: 2,
+ retry_interval_randomness: 0.5,
+ http_proxy: nil,
+ log_stream_debug: false
+ )
+ end
+
+ let(:provider) { described_class.new(config) }
+
+ describe '#initialize' do
+ context 'with valid configuration' do
+ it 'initializes successfully' do
+ expect { provider }.not_to raise_error
+ end
+ end
+
+ context 'without required API key' do
+ let(:config) do
+ instance_double(
+ RubyLLM::Configuration,
+ togetherai_api_key: nil,
+ request_timeout: 30,
+ max_retries: 3,
+ retry_interval: 1,
+ retry_backoff_factor: 2,
+ retry_interval_randomness: 0.5,
+ http_proxy: nil,
+ log_stream_debug: false
+ )
+ end
+
+ it 'raises a configuration error' do
+ expect { provider }.to raise_error(RubyLLM::ConfigurationError)
+ end
+ end
+ end
+
+ describe '#api_base' do
+ it 'returns the correct API base URL' do
+ expect(provider.api_base).to eq('https://api.together.xyz/v1')
+ end
+ end
+
+ describe '#headers' do
+ it 'returns proper authentication headers' do
+ expected_headers = {
+ 'Authorization' => 'Bearer test-api-key',
+ 'Content-Type' => 'application/json'
+ }
+
+ expect(provider.headers).to eq(expected_headers)
+ end
+
+ context 'when API key is nil' do
+ it 'excludes nil values from headers' do
+ # Create a provider instance and test headers with nil API key
+ provider_instance = described_class.allocate
+ provider_instance.instance_variable_set(:@config,
+ instance_double(RubyLLM::Configuration, togetherai_api_key: nil))
+
+ headers = provider_instance.headers
+
+ expect(headers).to eq({ 'Content-Type' => 'application/json' })
+ expect(headers).not_to have_key('Authorization')
+ end
+ end
+ end
+
+ describe '.capabilities' do
+ it 'returns the TogetherAI capabilities module' do
+ expect(described_class.capabilities).to eq(RubyLLM::Providers::TogetherAI::Capabilities)
+ end
+ end
+
+ describe '.configuration_requirements' do
+ it 'requires togetherai_api_key' do
+ expect(described_class.configuration_requirements).to eq([:togetherai_api_key])
+ end
+ end
+
+ describe 'included modules' do
+ it 'includes the Chat module' do
+ expect(described_class.included_modules).to include(RubyLLM::Providers::TogetherAI::Chat)
+ end
+
+ it 'includes the Models module' do
+ expect(described_class.included_modules).to include(RubyLLM::Providers::TogetherAI::Models)
+ end
+ end
+end
diff --git a/spec/support/rubyllm_configuration.rb b/spec/support/rubyllm_configuration.rb
index 25a2a5b90..5565b9ba2 100644
--- a/spec/support/rubyllm_configuration.rb
+++ b/spec/support/rubyllm_configuration.rb
@@ -14,6 +14,7 @@
config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', 'test')
config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', 'test')
config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', 'test')
+ config.togetherai_api_key = ENV.fetch('TOGETHERAI_API_KEY', nil)
config.ollama_api_base = ENV.fetch('OLLAMA_API_BASE', 'http://localhost:11434/v1')
config.gpustack_api_base = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:11444/v1')
diff --git a/spec/support/vcr_configuration.rb b/spec/support/vcr_configuration.rb
index 0cdd63e7f..b76c44a2a 100644
--- a/spec/support/vcr_configuration.rb
+++ b/spec/support/vcr_configuration.rb
@@ -25,6 +25,7 @@
config.filter_sensitive_data('') { ENV.fetch('PERPLEXITY_API_KEY', nil) }
config.filter_sensitive_data('') { ENV.fetch('OPENROUTER_API_KEY', nil) }
config.filter_sensitive_data('') { ENV.fetch('MISTRAL_API_KEY', nil) }
+ config.filter_sensitive_data('') { ENV.fetch('TOGETHERAI_API_KEY', nil) }
config.filter_sensitive_data('') { ENV.fetch('OLLAMA_API_BASE', 'http://localhost:11434/v1') }
config.filter_sensitive_data('') { ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:11444/v1') }