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 +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/VERSION +1 -0
- data/lib/litellm/client.rb +243 -0
- data/lib/litellm/configuration.rb +99 -0
- data/lib/litellm/errors.rb +98 -0
- data/lib/litellm/streaming.rb +156 -0
- data/lib/litellm/tool_handler.rb +93 -0
- data/lib/litellm/utils/tool_definition.rb +173 -0
- data/lib/litellm/version.rb +5 -0
- data/lib/litellm.rb +59 -0
- metadata +88 -0
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
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
|
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: []
|