litellm 1.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1dd4997378f275e63183ff9dad3c067f78937094193f9caa7524a9d81e11f8f7
4
+ data.tar.gz: b4ce66a0b829c04c0c7a0a797b38f310c980fb25bac1c2aacb5c032b03df1bc1
5
+ SHA512:
6
+ metadata.gz: 2af4c2ab3c5b56179738e794db8a74e54e8889feab46a0c1f1d026a2adb0bc5b6145955b6c3a96db36881c39bc6c4a1a429e6417a13e86f1893bc750e91e3ffa
7
+ data.tar.gz: 28b9ffdd35c4709fec7fcec727a00685727a0ce4ac332966928c47f9e4a89b78f817181c1357ee84b8c4dbf3a0813b28497a947155dfa110fe27b60a88d458ca
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 1.0.0.rc1 (2025-03-08)
2
+
3
+ - Feature - Initial preview release of the `litellm` ruby gem.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Alex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0.rc1
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiteLLM
4
+ class Client
5
+ Error = Class.new(StandardError)
6
+ ConfigurationError = Class.new(Error)
7
+ ConnectionError = Class.new(Error)
8
+ TimeoutError = Class.new(Error)
9
+ APIError = Class.new(Error)
10
+
11
+ # @param config [LiteLLM::Config] Configuration object
12
+ def initialize(config = LiteLLM.configuration)
13
+ @config = config
14
+ @messages = []
15
+
16
+ validate_configuration!
17
+ end
18
+
19
+ # Make a completion request
20
+ # @param messages [Array<Hash>] Array of message objects
21
+ # @param model [String, nil] Optional model override
22
+ # @param stream [Boolean] Whether to stream the response
23
+ # @param tools [Array<ToolDefinition>] Array of tools available for the request
24
+ # @return [Hash, Enumerator] Response data or stream
25
+ def completion(messages:, model: nil, stream: false, tools: [], system_message: nil, **options,
26
+ &block)
27
+ @messages = messages.dup
28
+ @messages = [{ role: "system", content: system_message }] + @messages if system_message
29
+
30
+ payload = build_payload(
31
+ messages: @messages,
32
+ model: model || @config.model,
33
+ stream: stream,
34
+ **options
35
+ )
36
+
37
+ payload[:tools] = build_tools_schema(tools) unless tools.empty?
38
+
39
+ if stream
40
+ raise ArgumentError, "Block required for streaming requests" unless block_given?
41
+
42
+ stream_completion(payload, tools, model, &block)
43
+ else
44
+ non_stream_completion(payload, tools, model)
45
+ end
46
+ end
47
+
48
+ # Generate embeddings for input text
49
+ # @param input [String, Array<String>] Input text(s)
50
+ # @param model [String, nil] Optional model override
51
+ # @param options [Hash] Additional options to pass to the API
52
+ # @return [Hash] Embedding response
53
+ def embedding(input:, model: nil, dimensions: nil, **options)
54
+ payload = build_payload(
55
+ input: input,
56
+ model: model || @config.embedding_model,
57
+ dimensions: dimensions || @config.embedding_dimensions,
58
+ **options
59
+ )
60
+
61
+ response = make_request("/embeddings", payload)
62
+
63
+ handle_json_response(response).dig("data", 0, "embedding")
64
+ end
65
+
66
+ # Generate images from a prompt
67
+ # @param prompt [String] Image generation prompt
68
+ # @param options [Hash] Additional options to pass to the API
69
+ # @return [Hash] Image generation response
70
+ def image_generation(prompt:, model: nil, **options)
71
+ payload = build_payload(
72
+ prompt: prompt,
73
+ model: model || @config.image_model,
74
+ **options
75
+ )
76
+
77
+ response = make_request("/images/generations", payload)
78
+
79
+ handle_json_response(response).dig("data", 0, "url")
80
+ end
81
+
82
+ private
83
+
84
+ def stream_completion(payload, tools, model, &block)
85
+ LiteLLM.logger.debug "Starting streaming completion request"
86
+
87
+ make_request("/chat/completions", payload) do |content, tool_call_payload|
88
+ if content
89
+ block.call(content)
90
+ elsif tool_call_payload
91
+ handle_tool_calls(tool_call_payload, tools, model, true, &block)
92
+ end
93
+ end
94
+
95
+ LiteLLM.logger.debug "Completed streaming request"
96
+ end
97
+
98
+ def non_stream_completion(payload, tools, model)
99
+ response = make_request("/chat/completions", payload)
100
+ response = handle_json_response(response, "/chat/completions")
101
+
102
+ tool_calls = response.dig("choices", 0, "message", "tool_calls")
103
+
104
+ if tool_calls&.any?
105
+ LiteLLM.logger.debug "Tool calls detected in non-streaming response"
106
+ handle_tool_calls(response, tools, model, false)
107
+ else
108
+ response.dig("choices", 0, "message", "content")
109
+ end
110
+ end
111
+
112
+ def build_payload(**params)
113
+ params = params
114
+ .transform_keys(&:to_sym)
115
+ .compact
116
+
117
+ LiteLLM.logger.debug "--------------------------------"
118
+ LiteLLM.logger.debug "LiteLLM Request: #{params.to_json}"
119
+ LiteLLM.logger.debug "--------------------------------"
120
+
121
+ params
122
+ end
123
+
124
+ def build_tools_schema(tools)
125
+ tools.flat_map do |tool|
126
+ unless tool.is_a?(Module) || tool.respond_to?(:to_tool_format)
127
+ raise ArgumentError, "Tool must include ToolDefinition module"
128
+ end
129
+
130
+ tool.to_tool_format
131
+ end
132
+ end
133
+
134
+ def make_request(endpoint, payload, &block)
135
+ request_id = SecureRandom.uuid
136
+ log_request(request_id, endpoint, payload)
137
+
138
+ response = connection.post(endpoint) do |req|
139
+ req.headers["X-Request-ID"] = request_id
140
+ req.body = payload.to_json
141
+ req.options.on_data = Streaming.process_stream(&block) if payload[:stream]
142
+ end
143
+
144
+ log_response(request_id, response)
145
+ response
146
+ rescue StandardError => e
147
+ log_error(request_id, e)
148
+ raise
149
+ end
150
+
151
+ def handle_tool_calls(response_data, tools, model, stream, &block)
152
+ LiteLLM.logger.debug "Handling tool calls, stream=#{stream}"
153
+
154
+ begin
155
+ LiteLLM.logger.debug "Tool call response: #{response_data.inspect}"
156
+
157
+ # Add tool-related messages to conversation history
158
+ tool_messages = ToolHandler.call(response: response_data, available_tools: tools)
159
+ @messages += tool_messages
160
+
161
+ LiteLLM.logger.debug "Tool handler generated messages: #{@messages.inspect}"
162
+
163
+ completion(messages: @messages, model: model, tools: tools, stream: stream, &block)
164
+ rescue StandardError => e
165
+ LiteLLM.logger.error "Error in handle_tool_calls: #{e.message}\n#{e.backtrace.join("\n")}"
166
+ raise
167
+ end
168
+ end
169
+
170
+ def handle_json_response(response, endpoint = nil)
171
+ LiteLLM.logger.debug <<~LOG
172
+ --------------------------------
173
+ LiteLLM RAW Response: #{response.inspect}
174
+ --------------------------------
175
+ LOG
176
+
177
+ parsed_response = ErrorHandler.parse_json(response.body)
178
+
179
+ ErrorHandler.validate_completion_response(parsed_response) if endpoint == "/chat/completions"
180
+
181
+ parsed_response
182
+ end
183
+
184
+ def connection
185
+ @connection ||= Faraday.new(url: @config.base_url) do |f|
186
+ f.request :json
187
+ f.response :raise_error
188
+
189
+ f.headers["X-LITELLM-TIMEOUT"] = @config.timeout.to_s
190
+
191
+ f.headers["Authorization"] = "Bearer #{@config.api_key}" if @config.api_key
192
+
193
+ f.headers["X-LITELLM-ENABLE-MESSAGE-REDACTION"] = true if @config.enable_message_redaction
194
+
195
+ f.options.timeout = @config.timeout
196
+ f.adapter Faraday.default_adapter
197
+ end
198
+ rescue URI::InvalidURIError => e
199
+ ErrorHandler.handle_error(
200
+ ConfigurationError,
201
+ "Invalid base URL: #{@config.base_url}",
202
+ e,
203
+ debug: @config.debug
204
+ )
205
+ end
206
+
207
+ def validate_configuration!
208
+ raise ConfigurationError, "Base URL is required" if @config.base_url.nil?
209
+
210
+ validate_base_url!
211
+ end
212
+
213
+ def validate_base_url!
214
+ uri = URI.parse(@config.base_url)
215
+ unless uri.scheme&.match?(/\Ahttps?\z/)
216
+ raise ConfigurationError, "Base URL must be HTTP or HTTPS"
217
+ end
218
+ rescue URI::InvalidURIError => e
219
+ raise ConfigurationError, "Invalid base URL: #{e.message}"
220
+ end
221
+
222
+ def log_request(request_id, endpoint, payload)
223
+ LiteLLM.logger.info("[#{request_id}] Request to #{endpoint}")
224
+ return unless LiteLLM.configuration.debug
225
+
226
+ LiteLLM.logger.debug("[#{request_id}] Payload: #{payload.inspect}")
227
+ end
228
+
229
+ def log_response(request_id, response)
230
+ LiteLLM.logger.info("[#{request_id}] Response received")
231
+ return unless LiteLLM.configuration.debug
232
+
233
+ LiteLLM.logger.debug("[#{request_id}] Response: #{response.inspect}")
234
+ end
235
+
236
+ def log_error(request_id, e)
237
+ LiteLLM.logger.error("[#{request_id}] Error: #{e.message}")
238
+ return unless LiteLLM.configuration.debug
239
+
240
+ LiteLLM.logger.debug("[#{request_id}] Backtrace: #{e.backtrace.join("\n")}")
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module LiteLLM
6
+ DEFAULTS = {
7
+ base_url: "http://localhost:8000",
8
+ timeout: 120,
9
+ model: "gpt-4o",
10
+ embedding_model: "text-embedding-3-large",
11
+ image_model: "dall-e-2",
12
+ embedding_dimensions: 1536,
13
+ enable_message_redaction: false,
14
+ debug: false,
15
+ api_key: ENV.fetch("LITE_LLM_API_KEY", nil)
16
+ }.freeze
17
+
18
+ class << self
19
+ attr_writer :configuration
20
+
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield(configuration)
27
+ end
28
+
29
+ def logger
30
+ configuration.logger
31
+ end
32
+ end
33
+
34
+ class Configuration
35
+ attr_reader :base_url, :timeout, :model, :embedding_model, :image_model, :embedding_dimensions,
36
+ :debug, :logger, :enable_message_redaction, :api_key
37
+
38
+ def initialize
39
+ @base_url = DEFAULTS[:base_url]
40
+ @timeout = DEFAULTS[:timeout]
41
+ @model = DEFAULTS[:model]
42
+ @embedding_model = DEFAULTS[:embedding_model]
43
+ @image_model = DEFAULTS[:image_model]
44
+ @embedding_dimensions = DEFAULTS[:embedding_dimensions]
45
+ @enable_message_redaction = DEFAULTS[:enable_message_redaction]
46
+ @debug = DEFAULTS[:debug]
47
+ @api_key = DEFAULTS[:api_key]
48
+ @logger = setup_default_logger
49
+ end
50
+
51
+ def base_url=(value)
52
+ @base_url = value unless value.nil?
53
+ end
54
+
55
+ def timeout=(value)
56
+ @timeout = value.to_i unless value.nil?
57
+ end
58
+
59
+ def model=(value)
60
+ @model = value unless value.nil?
61
+ end
62
+
63
+ def embedding_model=(value)
64
+ @embedding_model = value unless value.nil?
65
+ end
66
+
67
+ def image_model=(value)
68
+ @image_model = value unless value.nil?
69
+ end
70
+
71
+ def enable_message_redaction=(value)
72
+ @enable_message_redaction = value unless value.nil?
73
+ end
74
+
75
+ def api_key=(value)
76
+ @api_key = value unless value.nil?
77
+ end
78
+
79
+ def debug=(value)
80
+ @debug = if value.nil?
81
+ defined?(Rails) ? Rails.env.development? : false
82
+ else
83
+ value.to_s == "true"
84
+ end
85
+ end
86
+
87
+ def logger=(value)
88
+ @logger = value if value
89
+ end
90
+
91
+ private
92
+
93
+ def setup_default_logger
94
+ logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
95
+ logger.level = debug ? Logger::DEBUG : Logger::INFO
96
+ logger
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiteLLM
4
+ class BaseError < StandardError
5
+ attr_reader :original_error
6
+
7
+ def initialize(message = nil, original_error = nil)
8
+ super(message)
9
+ @original_error = original_error
10
+ end
11
+
12
+ def set_debug_backtrace(debug_mode)
13
+ return unless @original_error
14
+
15
+ set_backtrace(
16
+ debug_mode ? @original_error.backtrace : [@original_error.backtrace.first]
17
+ )
18
+ end
19
+ end
20
+
21
+ class ConfigurationError < BaseError; end
22
+ class APIError < BaseError; end
23
+ class TimeoutError < BaseError; end
24
+ class ConnectionError < BaseError; end
25
+ class ToolCallError < BaseError; end
26
+ class InsufficientQuotaError < APIError; end
27
+ class RateLimitError < APIError; end
28
+ class AuthenticationError < APIError; end
29
+ class ValidationError < APIError; end
30
+
31
+ module ErrorHandler
32
+ def self.handle_error(error_class, message, original_error = nil)
33
+ error = error_class.new(message, original_error)
34
+ error.set_debug_backtrace(LiteLLM.debug?)
35
+ raise error
36
+ end
37
+
38
+ def self.handle_api_error(error)
39
+ error_body = begin
40
+ parse_json(error.response[:body])
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ error_message = error_body&.dig("error", "message") || error.message
45
+
46
+ case error.response[:status]
47
+ when 401
48
+ handle_error(AuthenticationError, "Authentication failed: #{error_message}", error)
49
+ when 422
50
+ handle_error(ValidationError, "Validation failed: #{error_message}", error)
51
+ when 429
52
+ if error_body&.dig("error", "type") == "insufficient_quota"
53
+ handle_error(InsufficientQuotaError, "API quota exceeded", error)
54
+ else
55
+ handle_error(RateLimitError, "Rate limit exceeded", error)
56
+ end
57
+ else
58
+ handle_error(APIError, "API request failed (#{error.response[:status]}): #{error_message}",
59
+ error)
60
+ end
61
+ end
62
+
63
+ def self.validate_completion_response(response)
64
+ return if response["choices"]&.first&.dig("message")
65
+
66
+ raise APIError, "Invalid response format from server"
67
+ end
68
+
69
+ def self.configuration_error_message(api_key, base_url)
70
+ [
71
+ ("API key not configured" if api_key.nil?),
72
+ ("Base URL not configured" if base_url.nil?)
73
+ ].compact.join(" and ")
74
+ end
75
+
76
+ def self.handle_tool_error(message, original_error = nil)
77
+ error = ToolCallError.new(message, original_error)
78
+ error.set_debug_backtrace(LiteLLM.debug?)
79
+ raise error
80
+ end
81
+
82
+ private
83
+
84
+ def self.parse_json(body)
85
+ JSON.parse(body)
86
+ rescue JSON::ParserError => e
87
+ handle_error(APIError, "Invalid JSON response from server: #{e.message}", e)
88
+ end
89
+
90
+ def self.log_debug_response(response)
91
+ <<~LOG
92
+ --------------------------------
93
+ LiteLLM RAW Response: #{response.inspect}
94
+ --------------------------------
95
+ LOG
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiteLLM
4
+ module Streaming
5
+ class StreamingError < StandardError; end
6
+
7
+ class StreamBuffer
8
+ attr_reader :content
9
+
10
+ def initialize
11
+ @content = ""
12
+ @tool_calls = {}
13
+ @tool_call_id = nil
14
+ end
15
+
16
+ def add_chunk(parsed_chunk)
17
+ delta = parsed_chunk.dig("choices", 0, "delta")
18
+ return process_content(delta) if delta&.key?("content")
19
+ return process_tool_call(delta) if delta&.key?("tool_calls")
20
+
21
+ nil
22
+ end
23
+
24
+ def tool_calls?
25
+ @tool_calls.any?
26
+ end
27
+
28
+ def tool_call_payload
29
+ return nil unless tool_calls?
30
+
31
+ # Format the response to match standard API response format
32
+ {
33
+ "choices" => [
34
+ {
35
+ "message" => {
36
+ "tool_calls" => format_tool_calls
37
+ }
38
+ }
39
+ ]
40
+ }
41
+ end
42
+
43
+ def reset_tool_call_id
44
+ @tool_call_id = nil
45
+ end
46
+
47
+ private
48
+
49
+ def format_tool_calls
50
+ @tool_calls.values.map do |call|
51
+ {
52
+ "id" => call[:id],
53
+ "type" => call[:type] || "function",
54
+ "function" => {
55
+ "name" => call[:function_name],
56
+ "arguments" => call[:arguments]
57
+ }
58
+ }
59
+ end
60
+ end
61
+
62
+ def process_content(delta)
63
+ content = delta["content"].to_s
64
+ @content += content
65
+ content
66
+ end
67
+
68
+ def process_tool_call(delta)
69
+ delta["tool_calls"].each do |tool_call|
70
+ extract_tool_call_to_buffer(tool_call)
71
+ end
72
+ nil
73
+ end
74
+
75
+ def extract_tool_call_to_buffer(tool_call)
76
+ @tool_call_id = tool_call["id"] if tool_call["id"]
77
+
78
+ return unless @tool_call_id
79
+
80
+ @tool_calls[@tool_call_id] ||= {
81
+ id: @tool_call_id,
82
+ index: tool_call["index"],
83
+ function_name: nil,
84
+ type: tool_call["type"],
85
+ arguments: ""
86
+ }
87
+
88
+ if (type = tool_call["type"])
89
+ @tool_calls[@tool_call_id][:type] = type
90
+ end
91
+
92
+ if (function_name = tool_call.dig("function", "name"))
93
+ @tool_calls[@tool_call_id][:function_name] = function_name
94
+ end
95
+
96
+ if (new_arguments = tool_call.dig("function", "arguments"))
97
+ @tool_calls[@tool_call_id][:arguments] += new_arguments.to_s
98
+ end
99
+ end
100
+ end
101
+
102
+ class << self
103
+ def process_stream(&block)
104
+ return proc {} unless block
105
+
106
+ parser = EventStreamParser::Parser.new
107
+ buffer = StreamBuffer.new
108
+
109
+ proc do |chunk|
110
+ parser.feed(chunk) do |_type, data|
111
+ if data.strip == "[DONE]"
112
+ handle_done(buffer, &block)
113
+ next
114
+ end
115
+
116
+ process_chunk(data, buffer, &block)
117
+ end
118
+ end
119
+ rescue StandardError => e
120
+ log_error("Stream Processing Failed", e)
121
+ raise StreamingError, "Failed to process stream: #{e.message}"
122
+ end
123
+
124
+ private
125
+
126
+ def process_chunk(data, buffer, &block)
127
+ parsed_data = JSON.parse(data)
128
+ log_debug("Received chunk: #{data}")
129
+
130
+ if content = buffer.add_chunk(parsed_data)
131
+ block.call(content, nil)
132
+ end
133
+ rescue JSON::ParserError => e
134
+ log_error("Stream Chunk Parsing Failed", e, data: data)
135
+ end
136
+
137
+ def handle_done(buffer, &block)
138
+ return unless buffer.tool_calls?
139
+
140
+ tool_calls_response = buffer.tool_call_payload
141
+ log_debug("Tool call payload: #{tool_calls_response.inspect}")
142
+
143
+ block.call(nil, tool_calls_response)
144
+ buffer.reset_tool_call_id
145
+ end
146
+
147
+ def log_debug(message)
148
+ LiteLLM.logger.debug("[Streaming] #{message}")
149
+ end
150
+
151
+ def log_error(message, error, **context)
152
+ LiteLLM.logger.error("[Streaming] #{message}", error: error, **context)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiteLLM
4
+ module ToolHandler
5
+ class ToolCallError < BaseError; end
6
+
7
+ def self.call(response:, available_tools: [])
8
+ tool_calls = response.dig("choices", 0, "message", "tool_calls")
9
+ return response unless tool_calls&.any?
10
+
11
+ # Build a mapping of function names to tools
12
+ @available_tools = {}
13
+ available_tools.each do |tool|
14
+ tool.class.function_definitions.each do |function_name, definition|
15
+ @available_tools[definition[:name]] = tool
16
+ end
17
+ end
18
+
19
+ LiteLLM.logger.debug "Available tool functions: #{@available_tools.keys.inspect}"
20
+
21
+ messages = [
22
+ response.dig("choices", 0, "message").merge("role" => "assistant")
23
+ ]
24
+
25
+ tool_results = execute_tool_calls(tool_calls)
26
+ messages.concat(build_tool_messages(tool_results))
27
+
28
+ messages
29
+ end
30
+
31
+ private
32
+
33
+ def self.execute_tool_calls(tool_calls)
34
+ tool_calls.map do |tool_call|
35
+ function_name = tool_call.dig("function", "name")
36
+ arguments = parse_tool_arguments(tool_call.dig("function", "arguments"))
37
+
38
+ tool = @available_tools[function_name]
39
+
40
+ unless tool
41
+ raise ToolCallError,
42
+ "Tool function '#{function_name}' not available for this request: DEBUG: #{@available_tools.inspect}"
43
+ end
44
+
45
+ output = tool.execute_function(function_name, arguments)
46
+
47
+ LiteLLM.logger.info "Tool call executed: #{function_name} with arguments: #{arguments.inspect}, output: #{output.inspect}"
48
+
49
+ {
50
+ tool_call_id: tool_call["id"],
51
+ function_name: function_name,
52
+ output: output
53
+ }
54
+ rescue StandardError => e
55
+ handle_tool_execution_error(tool_call, e)
56
+ end
57
+ end
58
+
59
+ def self.parse_tool_arguments(arguments_json)
60
+ return {} if arguments_json.nil? || arguments_json.empty?
61
+
62
+ JSON.parse(arguments_json).transform_keys(&:to_sym)
63
+ rescue JSON::ParserError => e
64
+ raise ToolCallError.new("Invalid tool call arguments: #{e.message}", e)
65
+ end
66
+
67
+ def self.build_tool_messages(results)
68
+ results.map do |result|
69
+ {
70
+ "role" => "tool",
71
+ "tool_call_id" => result[:tool_call_id],
72
+ "name" => result[:function_name],
73
+ "content" => result[:output].to_s
74
+ }
75
+ end
76
+ end
77
+
78
+ def self.handle_tool_execution_error(tool_call, error)
79
+ function_name = tool_call.dig("function", "name")
80
+ arguments = tool_call.dig("function", "arguments")
81
+
82
+ LiteLLM.logger.error "Tool call failed: #{function_name} with arguments: #{arguments.inspect}"
83
+ LiteLLM.logger.error "Error details: #{error.class} - #{error.message}"
84
+ LiteLLM.logger.error error.backtrace.join("\n") if error.backtrace
85
+
86
+ {
87
+ tool_call_id: tool_call["id"],
88
+ function_name: function_name,
89
+ output: "Error executing tool: #{error.message}"
90
+ }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LiteLLM
6
+ module Utils
7
+ module ToolDefinition
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def define_function(method_name, description:, &block)
14
+ function_definitions[method_name] = {
15
+ name: "#{normalized_tool_name}__#{method_name}",
16
+ description: description,
17
+ parameters: build_parameters(&block)
18
+ }
19
+ end
20
+
21
+ def function_definitions
22
+ @function_definitions ||= {}
23
+ end
24
+
25
+ def normalized_tool_name
26
+ @normalized_tool_name ||= name.split("::").last.gsub(/Tool\z/, "")
27
+ end
28
+
29
+ private
30
+
31
+ def build_parameters(&block)
32
+ return nil unless block_given?
33
+
34
+ builder = ParameterBuilder.new
35
+ schema = builder.build(&block)
36
+
37
+ {
38
+ type: "object",
39
+ properties: schema[:properties],
40
+ required: schema[:required],
41
+ additionalProperties: false
42
+ }
43
+ end
44
+ end
45
+
46
+ def to_tool_format
47
+ self.class.function_definitions.values.map do |function|
48
+ { type: "function", function: function }
49
+ end
50
+ end
51
+
52
+ def execute_function(function_name, parameters = {})
53
+ method_name = function_name.to_s.split("__").last
54
+ log_prefix = "[#{self.class.name}##{method_name}]"
55
+
56
+ LiteLLM.logger.info("#{log_prefix} Called with parameters: #{parameters.inspect}")
57
+
58
+ unless respond_to?(method_name, true)
59
+ error_message = "Function '#{method_name}' not found in #{self.class.name}"
60
+ LiteLLM.logger.error("#{log_prefix} #{error_message}")
61
+ raise NoMethodError, error_message
62
+ end
63
+
64
+ begin
65
+ symbolized_params = parameters.transform_keys(&:to_sym)
66
+ validate_parameters(method_name, symbolized_params)
67
+
68
+ result = send(method_name, **symbolized_params)
69
+
70
+ LiteLLM.logger.info("#{log_prefix} Execution successful, returned: #{result.inspect}")
71
+ result
72
+ rescue ArgumentError => e
73
+ LiteLLM.logger.error("#{log_prefix} Parameter validation failed: #{e.message}")
74
+ raise
75
+ rescue StandardError => e
76
+ LiteLLM.logger.error("#{log_prefix} Execution failed: #{e.class} - #{e.message}")
77
+ LiteLLM.logger.error("#{log_prefix} Backtrace: #{e.backtrace.first(5).join("\n")}")
78
+ raise
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def validate_parameters(method_name, params)
85
+ function_def = self.class.function_definitions[method_name.to_sym]
86
+ return unless function_def && function_def[:parameters]
87
+
88
+ required_params = function_def[:parameters][:required] || []
89
+ missing_params = required_params - params.keys.map(&:to_s)
90
+
91
+ return if missing_params.empty?
92
+
93
+ raise ArgumentError, "Missing required parameters: #{missing_params.join(', ')}"
94
+ end
95
+
96
+ class ParameterBuilder
97
+ VALID_TYPES = %w[object array string number integer boolean null]
98
+
99
+ def initialize
100
+ @properties = {}
101
+ @required = []
102
+ end
103
+
104
+ def build(&block)
105
+ instance_eval(&block)
106
+ { properties: @properties, required: @required }
107
+ end
108
+
109
+ def property(name, type:, description: nil, enum: nil, required: false, default: nil,
110
+ &block)
111
+ validate_property_params(name, type, enum, required)
112
+
113
+ prop = {
114
+ type: type,
115
+ description: description,
116
+ enum: enum,
117
+ default: default
118
+ }.compact
119
+
120
+ if block_given?
121
+ nested_builder = ParameterBuilder.new
122
+ nested_schema = nested_builder.build(&block)
123
+
124
+ case type
125
+ when "object"
126
+ if nested_schema[:properties].empty?
127
+ raise ArgumentError, "Object properties must have at least one property"
128
+ end
129
+
130
+ prop[:properties] = nested_schema[:properties]
131
+ prop[:required] = nested_schema[:required] unless nested_schema[:required].empty?
132
+
133
+ when "array"
134
+ if nested_schema[:properties].empty?
135
+ raise ArgumentError, "Array items must be defined"
136
+ end
137
+
138
+ # For simplicity, use the first property definition as the items schema
139
+ # This assumes a single item type definition in the block
140
+ prop[:items] = nested_schema[:properties].values.first
141
+ end
142
+ end
143
+
144
+ @properties[name] = prop
145
+ @required << name.to_s if required
146
+ end
147
+
148
+ alias item property
149
+
150
+ private
151
+
152
+ def validate_property_params(name, type, enum, required)
153
+ unless name.is_a?(Symbol)
154
+ raise ArgumentError,
155
+ "Name must be a symbol, got: #{name.class}"
156
+ end
157
+
158
+ unless VALID_TYPES.include?(type)
159
+ raise ArgumentError, "Invalid type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
160
+ end
161
+
162
+ unless enum.nil? || enum.is_a?(Array)
163
+ raise ArgumentError, "Enum must be nil or an array, got: #{enum.class}"
164
+ end
165
+
166
+ return if required.is_a?(TrueClass) || required.is_a?(FalseClass)
167
+
168
+ raise ArgumentError, "Required must be boolean, got: #{required.class}"
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiteLLM
4
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip
5
+ end
data/lib/litellm.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'event_stream_parser'
5
+ require 'json'
6
+ require 'logger'
7
+
8
+ require_relative 'litellm/version'
9
+ require_relative 'litellm/configuration'
10
+ require_relative 'litellm/streaming'
11
+ require_relative 'litellm/client'
12
+ require_relative 'litellm/errors'
13
+ require_relative 'litellm/tool_handler'
14
+ require_relative 'litellm/utils/tool_definition'
15
+
16
+ module LiteLLM
17
+ class Error < StandardError; end
18
+
19
+ module Utils
20
+ end
21
+
22
+ class << self
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ end
30
+
31
+ def reset_configuration
32
+ @configuration = Configuration.new
33
+ end
34
+
35
+ def client
36
+ @client ||= Client.new
37
+ end
38
+
39
+ def completion(*args)
40
+ client.completion(*args)
41
+ end
42
+
43
+ def embedding(*args)
44
+ client.embedding(*args)
45
+ end
46
+
47
+ def image_generation(*args)
48
+ client.image_generation(*args)
49
+ end
50
+
51
+ def logger
52
+ configuration.logger
53
+ end
54
+
55
+ def debug?
56
+ configuration.debug
57
+ end
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: litellm
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Mohamed Nimir
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-03-09 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: event_stream_parser
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.0
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: 2.0.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 0.3.0
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: 2.0.0
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1'
46
+ description: A Ruby client for LiteLLM (the LLM proxy/gateway).
47
+ email:
48
+ - mohamed.nimir@eptikar.com
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - CHANGELOG.md
54
+ - LICENSE.txt
55
+ - VERSION
56
+ - lib/litellm.rb
57
+ - lib/litellm/client.rb
58
+ - lib/litellm/configuration.rb
59
+ - lib/litellm/errors.rb
60
+ - lib/litellm/streaming.rb
61
+ - lib/litellm/tool_handler.rb
62
+ - lib/litellm/utils/tool_definition.rb
63
+ - lib/litellm/version.rb
64
+ homepage: https://github.com/eptikar/litellm-ruby
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ source_code_uri: https://github.com/eptikar/ruby-litellm
69
+ changelog_uri: https://github.com/eptikar/ruby-litellm/blob/main/CHANGELOG.md
70
+ bug_tracker_uri: https://github.com/eptikar/ruby-litellm/issues
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 2.6.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.6.2
86
+ specification_version: 4
87
+ summary: LiteLLM Ruby Client.
88
+ test_files: []