llms 0.1.0

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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +160 -0
  4. data/bin/llms-chat +6 -0
  5. data/bin/llms-test-model-access +4 -0
  6. data/bin/llms-test-model-image-support +4 -0
  7. data/bin/llms-test-model-prompt-caching +4 -0
  8. data/bin/llms-test-model-tool-use +5 -0
  9. data/lib/llms/adapters/anthropic_message_adapter.rb +73 -0
  10. data/lib/llms/adapters/anthropic_tool_call_adapter.rb +20 -0
  11. data/lib/llms/adapters/base_message_adapter.rb +60 -0
  12. data/lib/llms/adapters/google_gemini_message_adapter.rb +72 -0
  13. data/lib/llms/adapters/google_gemini_tool_call_adapter.rb +20 -0
  14. data/lib/llms/adapters/open_ai_compatible_message_adapter.rb +88 -0
  15. data/lib/llms/adapters/open_ai_compatible_tool_call_adapter.rb +67 -0
  16. data/lib/llms/adapters.rb +12 -0
  17. data/lib/llms/apis/google_gemini_api.rb +45 -0
  18. data/lib/llms/apis/open_ai_compatible_api.rb +54 -0
  19. data/lib/llms/cli/base.rb +186 -0
  20. data/lib/llms/cli/chat.rb +92 -0
  21. data/lib/llms/cli/test_access.rb +79 -0
  22. data/lib/llms/cli/test_image_support.rb +92 -0
  23. data/lib/llms/cli/test_prompt_caching.rb +275 -0
  24. data/lib/llms/cli/test_tool_use.rb +108 -0
  25. data/lib/llms/cli.rb +12 -0
  26. data/lib/llms/conversation.rb +100 -0
  27. data/lib/llms/conversation_message.rb +60 -0
  28. data/lib/llms/conversation_tool_call.rb +14 -0
  29. data/lib/llms/conversation_tool_result.rb +15 -0
  30. data/lib/llms/exceptions.rb +33 -0
  31. data/lib/llms/executors/anthropic_executor.rb +247 -0
  32. data/lib/llms/executors/base_executor.rb +144 -0
  33. data/lib/llms/executors/google_gemini_executor.rb +212 -0
  34. data/lib/llms/executors/hugging_face_executor.rb +17 -0
  35. data/lib/llms/executors/open_ai_compatible_executor.rb +209 -0
  36. data/lib/llms/executors.rb +52 -0
  37. data/lib/llms/models/model.rb +86 -0
  38. data/lib/llms/models/provider.rb +48 -0
  39. data/lib/llms/models.rb +187 -0
  40. data/lib/llms/parsers/anthropic_chat_response_stream_parser.rb +184 -0
  41. data/lib/llms/parsers/google_gemini_chat_response_stream_parser.rb +128 -0
  42. data/lib/llms/parsers/open_ai_compatible_chat_response_stream_parser.rb +170 -0
  43. data/lib/llms/parsers/partial_json_parser.rb +77 -0
  44. data/lib/llms/parsers/sse_chat_response_stream_parser.rb +72 -0
  45. data/lib/llms/public_models.json +607 -0
  46. data/lib/llms/stream/event_emitter.rb +48 -0
  47. data/lib/llms/stream/events.rb +104 -0
  48. data/lib/llms/usage/cost_calculator.rb +75 -0
  49. data/lib/llms/usage/usage_data.rb +46 -0
  50. data/lib/llms.rb +16 -0
  51. metadata +243 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0bedbc3fd0a0429738df4a4272c7752269229ebbbb4291caf235ebdbd83cc88a
4
+ data.tar.gz: 903c0baae14561bbff1ff32af10e07e218b8a22bdee3eb56f8c8d1b5817e31da
5
+ SHA512:
6
+ metadata.gz: 55e3cf99b5c462155251de3eb6ca40c0469a67184422f79027e57ec760fb363484eb78759aa11a8e34c3887472a3bc51fdfe47c1f7a9e303188a9ddd047e65ac
7
+ data.tar.gz: efb53841dd57e9fd3c17050b1552c16dcc5dcdb04edfb2c7bb9ea5120f8e367e4c528b8b1ec756661d51ed0ba7e77c4878f0d0b410ecfb94bb44bccc04561123
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ben Lund
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # LLMs
2
+
3
+ A Ruby library for interacting with Large Language Model (LLM) providers including Anthropic, Google Gemini, X.ai, and other OpenAI-compatible API providers (including local models).
4
+
5
+ Supports streaming, event-handling, conversation management, tool-use, image input, and cost-tracking.
6
+
7
+
8
+ ## Current Version
9
+
10
+ 0.1.0
11
+
12
+ Just about usable in production.
13
+
14
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+ ```ruby
19
+ gem 'llms'
20
+ ```
21
+
22
+ Then run:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ Or install directly:
29
+
30
+ ```bash
31
+ gem install llms
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```ruby
37
+ require 'llms'
38
+
39
+ # Create an executor for a specific model
40
+ executor = LLMs::Executors.instance(
41
+ model_name: 'claude-sonnet-4-0',
42
+ temperature: 0.0,
43
+ max_completion_tokens: 1000
44
+ )
45
+
46
+ # Simple prompt execution
47
+ puts executor.execute_prompt("What is 2+2?")
48
+
49
+ # Streaming response
50
+ executor.execute_prompt("What is the airspeed velocitty of an unladen swallow?") do |chunk|
51
+ print chunk
52
+ end
53
+
54
+ # Simple Chat
55
+ conversation = LLMs::Conversation.new
56
+ while true
57
+ print "> "
58
+ conversation.add_user_message($stdin.gets)
59
+ response = executor.execute_conversation(conversation){|chunk| print chunk}
60
+ puts
61
+ conversation.add_assistant_message(response)
62
+ end
63
+
64
+ # Add a custom model
65
+ LLMs::Models.add_model('ollama', 'qwen3:8b',
66
+ executor: 'OpenAICompatibleExecutor', base_url: 'http://localhost:11434/api')
67
+
68
+ executor = LLMs::Executors.instance(model_name: 'qwen3:8b', api_key: 'none')
69
+ puts executor.execute_prompt("What is 2+2?")
70
+
71
+ # Handle Streaming Events
72
+
73
+ executor.stream_conversation(conversation) do |emitter|
74
+ emitter.on :tool_call_completed do |event|
75
+ puts event.name
76
+ puts event.arguments.inspect
77
+ end
78
+ end
79
+ ```
80
+
81
+ ## Supported Models
82
+
83
+ LLMs from Anthropic, Google, xAI, and various open-weight inference hosts are pre-configured in this release. See `lib/llms/public_models.json` for the full list. No models from OpenAI are pre-configured but you can set them up them manually in your application code along the lines of the exmaple above.
84
+
85
+
86
+ ## Configuration
87
+
88
+ Set your API keys as environment variables:
89
+
90
+ ```bash
91
+ export ANTHROPIC_API_KEY="your-anthropic-key"
92
+ export GOOGLE_GEMINI_API_KEY="your-gemini-key"
93
+ ```
94
+
95
+ See lib/public_models.json for supported providers and their corresponding API key env vars.
96
+
97
+ Or pass to directly into Executor initialization:
98
+
99
+ ```ruby
100
+
101
+ require 'llms'
102
+
103
+ executor = LLMs::Executors::AnthropicExecutor.new(
104
+ model_name: 'claude-sonnet-4-0',
105
+ temperature: 0.0,
106
+ max_completion_tokens: 1000,
107
+ api_key: 'api-key-here'
108
+ )
109
+ ```
110
+
111
+
112
+
113
+ ## CLI Usage
114
+
115
+ ### Interactive Chat
116
+
117
+ ```bash
118
+ llms-chat --model model-name
119
+ ```
120
+
121
+ or if model-name would be ambiguous:
122
+
123
+ ```bash
124
+ llms-chat --model provider:model-name
125
+ ```
126
+
127
+ or to run against a local model (e.g. LMStudio):
128
+
129
+ ```bash
130
+ llms-chat --oac-base-url "http://127.0.0.1:1234/v1" -m qwen/qwen3-32b --oac-api-key none
131
+ ```
132
+
133
+ ### List Available Models
134
+
135
+ ```bash
136
+ llms-chat --list-models
137
+ ```
138
+
139
+ ### Test various features against all models
140
+
141
+ ```bash
142
+ llms-test-model-access # send a cshort question with a custom system prompt to all models in turn
143
+ llms-test-model-tool-usage # configures a simple tool and asks all models in turn to call it
144
+ llms-test-model-image-support # sends an image to every model asking it to describe the image
145
+ llms-test-prompt-caching # send a long prompt and see if it is cached
146
+ ```
147
+
148
+ ## Development
149
+
150
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rspec` to run the tests.
151
+
152
+
153
+ ## Contributing
154
+
155
+ Bug reports and pull requests are welcome on GitHub at https://github.com/benlund/llms
156
+
157
+
158
+ ## License
159
+
160
+ This gem is available as open source under the terms of the MIT License.
data/bin/llms-chat ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #!/usr/bin/env ruby
4
+
5
+ require_relative '../lib/llms/cli'
6
+ LLMs::CLI::Chat.new('llms-chat').run
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/llms/cli'
4
+ LLMs::CLI::TestAccess.new('llms-test-model-access').run
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/llms/cli'
4
+ LLMs::CLI::TestImageSupport.new('llms-test-model-image-support').run
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/llms/cli'
4
+ LLMs::CLI::TestPromptCaching.new('llms-test-model-prompt-caching').run
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/llms/cli'
4
+
5
+ LLMs::CLI::TestToolUse.new('llms-test-model-tool-use').run
@@ -0,0 +1,73 @@
1
+ require_relative '../conversation_message'
2
+ require_relative './base_message_adapter'
3
+ require_relative './anthropic_tool_call_adapter'
4
+
5
+ module LLMs
6
+ module Adapters
7
+ class AnthropicMessageAdapter < BaseMessageAdapter
8
+
9
+
10
+ def self.to_api_format(message, caching_enabled = false)
11
+ content = []
12
+
13
+ message.tool_results&.each do |tool_result|
14
+ tr = {type: 'tool_result', tool_use_id: tool_result.tool_call_id, content: tool_result.results}
15
+ if tool_result.is_error
16
+ tr[:is_error] = true
17
+ end
18
+ content << tr
19
+ end
20
+
21
+ message.parts&.each do |part|
22
+ if part[:text]
23
+ content << {type: 'text', text: part[:text]}
24
+ end
25
+ if part[:image]
26
+ content << {
27
+ type: 'image',
28
+ source: {
29
+ type: 'base64',
30
+ media_type: part[:media_type],
31
+ data: part[:image]
32
+ }
33
+ }
34
+ end
35
+ end
36
+
37
+ message.tool_calls&.each do |tool_call|
38
+ content << {type: 'tool_use', id: tool_call.tool_call_id, name: tool_call.name, input: tool_call.arguments}
39
+ end
40
+
41
+ if caching_enabled
42
+ content.last[:cache_control] = {type: 'ephemeral'}
43
+ end
44
+
45
+ {
46
+ role: message.role,
47
+ content: content
48
+ }
49
+ end
50
+
51
+ def self.transform_tool_call(api_response_format_part, index)
52
+ LLMs::Adapters::AnthropicToolCallAdapter.from_api_format(api_response_format_part, index)
53
+ end
54
+
55
+ def self.find_role(api_response_format)
56
+ api_response_format['role']
57
+ end
58
+
59
+ def self.find_message_id(api_response_format)
60
+ api_response_format['id']
61
+ end
62
+
63
+ def self.find_text(api_response_format)
64
+ api_response_format['content']&.map { |c| c['text'] }&.compact&.join
65
+ end
66
+
67
+ def self.find_tool_calls(api_response_format)
68
+ api_response_format['content']&.select { |c| c['type'] == 'tool_use' }
69
+ end
70
+
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,20 @@
1
+ require_relative '../conversation_tool_call'
2
+
3
+ module LLMs
4
+ module Adapters
5
+ class AnthropicToolCallAdapter
6
+
7
+ def self.from_api_format(api_response_format_part, index)
8
+ LLMs::ConversationToolCall.new(
9
+ index,
10
+ api_response_format_part['id'],
11
+ api_response_format_part['type'],
12
+ api_response_format_part['name'],
13
+ api_response_format_part['input'],
14
+ )
15
+ end
16
+
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,60 @@
1
+ require_relative '../conversation_message'
2
+
3
+ module LLMs
4
+ module Adapters
5
+ class BaseMessageAdapter
6
+
7
+
8
+ def self.to_api_format(message, caching_enabled = false)
9
+ raise "Not implemented"
10
+ end
11
+
12
+ def self.message_from_api_format(api_format)
13
+ if self.has_message?(api_format)
14
+ role = transform_role(find_role(api_format))
15
+ text = transform_text(find_text(api_format))
16
+ tool_calls = transform_tool_calls(find_tool_calls(api_format))
17
+ LLMs::ConversationMessage.new(role, [{text: text}], tool_calls, nil)
18
+ else
19
+ nil
20
+ end
21
+ end
22
+
23
+ def self.has_message?(api_format)
24
+ !find_role(api_format).nil?
25
+ end
26
+
27
+ def self.transform_role(role)
28
+ role
29
+ end
30
+
31
+ def self.transform_text(text)
32
+ text
33
+ end
34
+
35
+ def self.transform_tool_calls(tool_calls)
36
+ tool_calls.nil? ? nil : tool_calls.map.with_index { |tool_call, index| transform_tool_call(tool_call, index) }
37
+ end
38
+
39
+ def self.transform_tool_call(tool_call, index)
40
+ raise "Not implemented"
41
+ end
42
+
43
+ def self.find_message_id(api_format)
44
+ raise "Not implemented"
45
+ end
46
+
47
+ def self.find_role(api_response_format)
48
+ raise "Not implemented"
49
+ end
50
+
51
+ def self.find_text(api_response_format)
52
+ raise "Not implemented"
53
+ end
54
+
55
+ def self.find_tool_calls(api_response_format)
56
+ raise "Not implemented"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ require_relative '../conversation_message'
2
+ require_relative './base_message_adapter'
3
+ require_relative './google_gemini_tool_call_adapter'
4
+
5
+ module LLMs
6
+ module Adapters
7
+ class GoogleGeminiMessageAdapter < BaseMessageAdapter
8
+
9
+ def self.to_api_format(message)
10
+ parts = []
11
+ ## TODO order is important here - results must be given in same order as corresponding calls.
12
+ ## Currently the order is just assumed to be correctly preserved in the tool_results array
13
+ message.tool_results&.each do |tool_result|
14
+ parts << {functionResponse: { name: tool_result.name, response: { name: tool_result.name, content: tool_result.results}}}
15
+ end
16
+
17
+ message.parts&.each do |part|
18
+ if part[:text]
19
+ parts << {text: part[:text]}
20
+ end
21
+ if part[:image]
22
+ parts << {
23
+ inline_data: {
24
+ mime_type: part[:media_type] || 'image/png',
25
+ data: part[:image]
26
+ }
27
+ }
28
+ end
29
+ end
30
+
31
+
32
+ message.tool_calls&.each do |tool_call|
33
+ parts << {functionCall: {name: tool_call.name, args: tool_call.arguments}}
34
+ end
35
+ {
36
+ role: reverse_transform_role(message.role), ##TODO also gets changed later, fix this
37
+ parts: parts
38
+ }
39
+ end
40
+
41
+ def self.transform_role(role)
42
+ role == 'model' ? 'assistant' : role
43
+ end
44
+
45
+ def self.reverse_transform_role(role)
46
+ role == 'assistant' ? 'model' : role
47
+ end
48
+
49
+ def self.transform_tool_call(api_response_format_part, index)
50
+ LLMs::Adapters::GoogleGeminiToolCallAdapter.from_api_format(api_response_format_part, index)
51
+ end
52
+
53
+ def self.find_role(api_response_format)
54
+ api_response_format.dig('candidates', 0, 'role') || api_response_format.dig('candidates', 0, 'content', 'role')
55
+ end
56
+
57
+ def self.find_message_id(api_response_format)
58
+ nil ## no message id in the response
59
+ end
60
+
61
+ def self.find_text(api_response_format)
62
+ text_parts = api_response_format.dig('candidates', 0, 'content', 'parts')&.map { |c| c['text'] }&.compact
63
+ text_parts.nil? || text_parts.empty? ? nil : text_parts.join
64
+ end
65
+
66
+ def self.find_tool_calls(api_response_format)
67
+ api_response_format.dig('candidates', 0, 'content', 'parts')&.map { |c| c['functionCall'] }&.compact
68
+ end
69
+
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,20 @@
1
+ require_relative '../conversation_tool_call'
2
+
3
+ module LLMs
4
+ module Adapters
5
+ class GoogleGeminiToolCallAdapter
6
+
7
+ def self.from_api_format(api_response_format_part, index)
8
+ LLMs::ConversationToolCall.new(
9
+ index,
10
+ nil,
11
+ nil,
12
+ api_response_format_part['name'],
13
+ api_response_format_part['args'],
14
+ )
15
+ end
16
+
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,88 @@
1
+ require_relative '../conversation_message'
2
+ require_relative './base_message_adapter'
3
+ require_relative './open_ai_compatible_tool_call_adapter'
4
+
5
+ module LLMs
6
+ module Adapters
7
+ class OpenAICompatibleMessageAdapter < BaseMessageAdapter
8
+
9
+ def self.to_api_format(message, _caching_enabled = false)
10
+ formatted_messages = []
11
+
12
+ message.tool_results&.each do |tool_result|
13
+ formatted_messages << {
14
+ role: 'tool',
15
+ name: tool_result.name,
16
+ tool_call_id: tool_result.tool_call_id,
17
+ content: tool_result.results
18
+ }
19
+ end
20
+
21
+ m = {
22
+ role: message.role
23
+ }
24
+
25
+ if message.system? && message.text
26
+ m[:content] = message.text
27
+ else
28
+ has_images = message.parts&.any? { |part| part[:image] }
29
+
30
+ if has_images
31
+ m[:content] = []
32
+ message.parts&.each do |part|
33
+ if part[:text]
34
+ m[:content] << {type: 'text', text: part[:text]}
35
+ end
36
+
37
+ if part[:image]
38
+ m[:content] << {type: 'image_url', image_url: {url: "data:#{part[:media_type] || 'image/png'};base64,#{part[:image]}"}}
39
+ end
40
+ end
41
+ else
42
+ ## TODO check this
43
+ # For text-only messages, use array format to match test expectations
44
+ m[:content] = [{type: 'text', text: message.text}]
45
+ end
46
+ end
47
+
48
+ message.tool_calls&.each do |tool_call|
49
+ m[:tool_calls] ||= []
50
+ arguments = tool_call.arguments.is_a?(String) ? tool_call.arguments : JSON.dump(tool_call.arguments)
51
+ m[:tool_calls] << {
52
+ id: tool_call.tool_call_id,
53
+ type: 'function',
54
+ function: {
55
+ name: tool_call.name,
56
+ arguments: arguments
57
+ }
58
+ }
59
+ end
60
+
61
+ formatted_messages << m if m[:content] || m[:tool_calls]
62
+
63
+ formatted_messages
64
+ end
65
+
66
+ def self.transform_tool_call(api_response_format_part, index)
67
+ LLMs::Adapters::OpenAICompatibleToolCallAdapter.from_api_format(api_response_format_part, index)
68
+ end
69
+
70
+ def self.find_role(api_response_format)
71
+ api_response_format.dig('choices', 0, 'message', 'role')
72
+ end
73
+
74
+ def self.find_message_id(api_response_format)
75
+ api_response_format['id']
76
+ end
77
+
78
+ def self.find_text(api_response_format)
79
+ api_response_format.dig('choices', 0, 'message', 'content')
80
+ end
81
+
82
+ def self.find_tool_calls(api_response_format)
83
+ api_response_format.dig('choices', 0, 'message', 'tool_calls')
84
+ end
85
+
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,67 @@
1
+ require_relative '../conversation_tool_call'
2
+
3
+ module LLMs
4
+ module Adapters
5
+ class OpenAICompatibleToolCallAdapter
6
+
7
+ def self.from_api_format(api_response_format_part, index)
8
+ name = api_response_format_part.dig('function', 'name')
9
+ arguments_value = api_response_format_part.dig('function', 'arguments')
10
+
11
+ if work_around_arguments_value = hyperbolic_workaround(arguments_value)
12
+ function_value = work_around_arguments_value['function']
13
+ name = function_value['_name']
14
+ function_value.delete('_name')
15
+ arguments_value = function_value
16
+ end
17
+
18
+ if arguments_value.nil?
19
+ {}
20
+
21
+ elsif arguments_value.is_a?(String)
22
+ arguments = JSON.parse(arguments_value)
23
+
24
+ # Hyperbolic returns a hash (in correct format) in non-streaming mode
25
+ elsif arguments_value.is_a?(Hash)
26
+ arguments = arguments_value
27
+
28
+ else
29
+ raise "Unexpected arguments value: #{arguments_value}"
30
+ end
31
+
32
+ # Some endpoints (Together at least?) return nested JSON strings that need parsing
33
+ arguments.each do |key, value|
34
+ if value.is_a?(String) && ( (value.start_with?('{') && value.end_with?('}')) || ((value.start_with?('[') && value.end_with?(']'))) )
35
+ begin
36
+ arguments[key] = JSON.parse(value)
37
+ rescue JSON::ParserError
38
+ # If it's not valid JSON, keep the original string
39
+ end
40
+ end
41
+ end
42
+
43
+ LLMs::ConversationToolCall.new(
44
+ index,
45
+ api_response_format_part['id'],
46
+ api_response_format_part['type'],
47
+ name,
48
+ arguments,
49
+ )
50
+ end
51
+
52
+ ## Work around for Hyperbolic bugs:
53
+ ## 1. In streaming mode Hyperbolic returns an arguments string that ends with '<|im_end|>'
54
+ ## 2. In streming mode, the function name is null, and the arguments JSON has an additional nested level with _name key and arguments key
55
+ ## 3. In non-streaming mode Hyperbolic returns an arguments hash, not a JSON parsable string (but we deal with that above)
56
+ ## Returns a hash with the parsed arguments (in uncorrected Hyperbolic format) if they are detected, otherwise nil
57
+ def self.hyperbolic_workaround(arguments_value)
58
+ if arguments_value.is_a?(String) && arguments_value.end_with?('<|im_end|>')
59
+ JSON.parse(arguments_value[0..-11])
60
+ else
61
+ nil
62
+ end
63
+ end
64
+
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,12 @@
1
+ require_relative 'adapters/base_message_adapter'
2
+ require_relative 'adapters/anthropic_message_adapter'
3
+ require_relative 'adapters/anthropic_tool_call_adapter'
4
+ require_relative 'adapters/google_gemini_message_adapter'
5
+ require_relative 'adapters/google_gemini_tool_call_adapter'
6
+ require_relative 'adapters/open_ai_compatible_message_adapter'
7
+ require_relative 'adapters/open_ai_compatible_tool_call_adapter'
8
+
9
+ module LLMs
10
+ module Adapters
11
+ end
12
+ end
@@ -0,0 +1,45 @@
1
+ require 'net/http'
2
+ require 'json'
3
+
4
+ module LLMs
5
+ module APIs
6
+ class GoogleGeminiAPI
7
+
8
+ def initialize(api_key)
9
+ @api_key = api_key
10
+ end
11
+
12
+ def generate_content(model_name, messages, params = {})
13
+ stream = params.delete(:stream)
14
+
15
+ path = "/#{model_name}:#{!!stream ? 'streamGenerateContent?alt=sse&' : 'generateContent?'}key=#{@api_key}"
16
+ uri = URI("https://generativelanguage.googleapis.com/v1beta/models#{path}")
17
+ http = Net::HTTP.new(uri.host, uri.port)
18
+ http.use_ssl = true
19
+
20
+ request = Net::HTTP::Post.new(uri)
21
+ request['Content-Type'] = 'application/json'
22
+
23
+ request.body = params.merge({
24
+ contents: messages
25
+ }).to_json
26
+
27
+ if stream
28
+ http.request(request) do |response|
29
+ if response.code.to_s == '200'
30
+ response.read_body do |data|
31
+ stream.call(data)
32
+ end
33
+ return nil ## return nil to indicate that there's no separate api response other than the stream
34
+ else
35
+ return JSON.parse(response.body)
36
+ end
37
+ end
38
+ else
39
+ response = http.request(request)
40
+ JSON.parse(response.body)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end