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') }