intelligence 0.7.1 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 910d4c8472c375a7a3759c474d62e71c01395136d895ccff9dd2f33012fd5a39
4
- data.tar.gz: 86b51ac28f93a39556664c3707f341d40fe5aa4ec1cdc27a217586fd222c0c77
3
+ metadata.gz: 35084b4f3df27ee21c0a21a759b74bcca9c05c6b47e0311026d6e6587f8661a3
4
+ data.tar.gz: 76afc7ad3e1f2e3637c82e8613b492680be20962ca5daf263d3c457968512c04
5
5
  SHA512:
6
- metadata.gz: 6d47d1f1d333cb1f0ffe8bdb06bf27ad9be4c675349c01460f97021cc733df2410ed7bc597324b3413d44d0ed081a7a0ae129f0173313f4df5400d9012ed37b9
7
- data.tar.gz: 90f91d3efdf84c091252d4e16dbbcab41a7f27b39350974a62d80eee37d465f1dfc000b1efb836471d00d47d9a774050cf0f28c3ada96d6ba9947d8f54a6c209
6
+ metadata.gz: 643f9acfde921655b5861901f5ea11646d00e9673a852e8546ec112a275d9f4e695934c314c577ca5cf44684ee3ce86b57cb5da9100e67b5c016c3bd90672f14
7
+ data.tar.gz: 6a9bb70335d3cd9f5b5ef48f1029e8b7997d880a1ebed37168c6d5cae88ec4abb48c2c8ffb95ef63c5b4b82a0e5c00904b0320321c124d8cf24fabb1928e5453
data/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # Intelligence
2
2
 
3
3
  Intelligence is a lightweight yet powerful Ruby gem that provides a uniform interface for
4
- interacting with large language and vision model APIs across multiple providers. It allows
5
- you to seamlessly integrate with services from OpenAI, Anthropic, Google, Cerebras, Groq,
6
- Hyperbolic, Samba Nova, Together AI, and others, while maintaining a consistent API across
7
- all providers.
4
+ interacting with large language and vision model APIs across multiple vendors. It allows
5
+ you to seamlessly integrate with services from OpenAI, Anthropic, Google, Mistral, Cerebras,
6
+ Groq, Hyperbolic, Samba Nova, Together AI, and others, while maintaining a consistent API
7
+ across all providers.
8
8
 
9
9
  The gem operates with minimal dependencies and doesn't require vendor SDK installation,
10
10
  making it easy to switch between providers or work with multiple providers simultaneously.
11
11
 
12
- ```
12
+ ```ruby
13
13
  require 'intelligence'
14
14
 
15
15
  adapter = Intelligence::Adapter.build :open_ai do
@@ -61,10 +61,11 @@ $ gem install intelligence
61
61
 
62
62
  ## Usage
63
63
 
64
- ### Minimal Chat Request
64
+ ### Fundamentals
65
65
 
66
66
  The core components of Intelligence are adapters, requests and responses. An adapter encapsulates
67
- the differences between different providers allowing you to use requests and responses uniformly.
67
+ the differences between different API vendors, allowing you to use requests and responses
68
+ uniformly.
68
69
 
69
70
  You retrieve an adapter for a specific vendor, configure it with a key, model and associated
70
71
  parameters and then make a request by calling either the `chat` or `stream` methods.
@@ -94,25 +95,25 @@ else
94
95
  end
95
96
  ```
96
97
 
97
- The `response` object is a Faraday response with an added method: `result`. If a response is
98
+ The `response` object is a `Faraday` response with an added method: `result`. If a response is
98
99
  successful `result` returns a `ChatResult`. If it is not successful it returns a
99
- `ChatErrorResult`.
100
+ `ChatErrorResult`. You can use the `Faraday` method `success?` to determine if the response is
101
+ successful.
100
102
 
101
- ### Understanding Results
103
+ ### Results
102
104
 
103
105
  When you make a request using Intelligence, the response includes a `result` that provides
104
106
  structured access to the model's output.
105
107
 
106
108
  - A `ChatResult` contains one or more `choices` (alternate responses from the model). The
107
- `choices` method returns an array of `ChatResultChoice` instances. It also includes
108
- a `metrics` methods which provides information about token usage for the request.
109
- optional `metrics` about token usage
109
+ `choices` method returns an array of `ChatResultChoice` instances. `ChatResult` also
110
+ includes a `metrics` methods which provides information about token usage for the request.
110
111
  - A `ChatResultChoice` contains a `message` from the assistant and an `end_result` which
111
- indicates how the response ended;
112
+ indicates how the response ended:
112
113
  - `:ended` means the model completed its response normally
113
114
  - `:token_limit_exceeded` means the response hit the token limit ( `max_tokens` )
114
115
  - `:end_sequence_encountered` means the response hit a stop sequence
115
- - `:filtered` means the content was filtered by safety settings
116
+ - `:filtered` means the content was filtered by the vendors safety settings or protocols
116
117
  - `:tool_called` means the model is requesting to use a tool
117
118
  - The `Message` in each choice contains one or more content items, typically text but
118
119
  potentially tool calls or other content types.
@@ -157,7 +158,7 @@ if response.success?
157
158
  puts "Total tokens: #{result.metrics.total_tokens}"
158
159
  end
159
160
  else
160
- # or alternativelly handle the end result
161
+ # or alternativelly handle the error result
161
162
  puts "Error: #{response.result.error_description}"
162
163
  end
163
164
  ```
@@ -165,32 +166,26 @@ end
165
166
  The `ChatResult`, `ChatResultChoice` and `Message` all provide the `text` convenience
166
167
  method which return the text.
167
168
 
168
- A response might end for various reasons, indicated by the `end_reason` in each choice:
169
- - `:ended` means the model completed its response normally
170
- - `:token_limit_exceeded` means the response hit the token limit
171
- - `:end_sequence_encountered` means the response hit a stop sequence
172
- - `:filtered` means the content was filtered by safety settings
173
- - `:tool_called` means the model is requesting to use a tool
174
-
175
- ### Understanding Conversations, Messages, and Content
169
+ ### Conversations, Messages, and Content
176
170
 
177
171
  Intelligence organizes interactions with models using three main components:
178
172
 
179
173
  - **Conversations** are collections of messages that represent a complete interaction with a
180
- model. A conversation can include an optional system message that sets the context, and a
181
- series of back-and-forth messages between the user and assistant.
174
+ model. A conversation can include an optional system message that sets the context, a series
175
+ of back-and-forth messages between the user and assistant and any tools the model may call.
182
176
 
183
177
  - **Messages** are individual communications within a conversation. Each message has a role
184
178
  (`:system`, `:user`, or `:assistant`) that identifies its sender and can contain multiple
185
179
  pieces of content.
186
180
 
187
181
  - **Content** represents the actual data within a message. This can be text
188
- (`MessageContent::Text`), binary data like images (`MessageContent::Binary`), or references
189
- to files (`MessageContent::File`).
182
+ ( `MessageContent::Text` ), binary data like images ( `MessageContent::Binary` ), references
183
+ to files ( `MessageContent::File` ) or tool calls or tool results ( `MessageContent::ToolCall`
184
+ or `MessageContent::ToolResult` respectivelly ).
190
185
 
191
186
  In the previous examples we used a simple string as an argument to `chat`. As a convenience,
192
- the `chat` methods builds a coversation for you but, typically, you will construct a coversation
193
- instance (`Coversation`) and pass that to the chat or stream methods.
187
+ the `chat` methods builds a coversation for you from a String but, typically, you will construct
188
+ a coversation instance ( `Coversation` ) and pass that to the chat or stream methods.
194
189
 
195
190
  The following example expands the minimal example, building a conversation, messages and content:
196
191
 
@@ -289,7 +284,7 @@ This pattern allows you to maintain context across multiple interactions with th
289
284
  request includes the full conversation history, helping the model provide more contextually
290
285
  relevant responses.
291
286
 
292
- ### Using Builders
287
+ ### Builders
293
288
 
294
289
  For more readable configuration, Intelligence provides builder syntax for both adapters and
295
290
  conversations.
@@ -454,12 +449,15 @@ own descriptions and requirements. Once defined, tools are added to conversation
454
449
  used by the model during its response.
455
450
 
456
451
  Note that not all providers support tools, and the specific tool capabilities may vary between
457
- providers. Check your provider's documentation for details on tool support and requirements.
452
+ providers. Today, OpenAI, Anthropic, Google, Mistral, and Together AI support tools. In general
453
+ all these providers support tools in an identical manner but as of this writing Google does not
454
+ support 'complex' tools which take object parameters.
458
455
 
459
456
  ## Streaming Responses
460
457
 
461
- Once you're familiar with basic requests, you might want to use streaming for real-time
462
- responses. Streaming delivers the model's response in chunks as it's generated:
458
+ The `chat` method, while straightforward in implementation, can be time consuming ( especially
459
+ when using modern 'reasoning' models like OpenAI O1 ). The alternative is to use the `stream`
460
+ method which will receive results as these are generated by the model.
463
461
 
464
462
  ```ruby
465
463
  adapter = Intelligence::Adapter.build! :anthropic do
@@ -473,45 +471,68 @@ end
473
471
 
474
472
  request = Intelligence::ChatRequest.new(adapter: adapter)
475
473
 
476
- response = request.stream("Tell me a story about a robot.") do |request|
477
- request.receive_result do |result|
474
+ response = request.stream( "Tell me a story about a robot." ) do | request |
475
+ request.receive_result do | result |
478
476
  # result is a ChatResult object with partial content
479
- print result.text
477
+ print result.text
480
478
  print "\n" if result.choices.first.end_reason
481
479
  end
482
480
  end
483
481
  ```
484
482
 
485
- Streaming also works with complex conversations and binary content:
483
+ Notice that in this approach you will receive multiple results ( `ChatResult` instances )
484
+ each with a fragment of the generation. The result always includes a `message` and will
485
+ include `contents` as soon as any content is received. The `contents` is always positionally
486
+ consitent, meaning that if a model is, for example, generating text followed by several
487
+ tool calls you may receive a single text content initially, then the text content and a tool,
488
+ and then subsequent tools, even after the text has been completely generated.
489
+
490
+ Remember that every `result` contains only a fragment of content and it is possible that
491
+ any given fragment is completely blank ( that is, it is possible for the content to be
492
+ present in the result but all of it's fields are nil ).
493
+
494
+ While you will likelly want to immediatelly output any generated text but, as practical matter,
495
+ tool calls are not useful until full generated. To assemble tool calls ( or the text ) from
496
+ the text fragments you may use the content items `merge` method.
486
497
 
487
498
  ```ruby
488
- conversation = Intelligence::Conversation.build do
489
- system_message do
490
- content text: "You are an image analysis expert."
491
- end
492
-
493
- message do
494
- role :user
495
- content text: "Describe this image in detail"
496
- content do
497
- type :binary
498
- content_type 'image/jpeg'
499
- bytes File.binread('path/to/image.jpg')
500
- end
501
- end
502
- end
499
+ request = Intelligence::ChatRequest.new( adapter: adapter )
503
500
 
504
- response = request.stream(conversation) do |request|
505
- request.receive_result do |result|
506
- result.choices.each do |choice|
507
- choice.message.each_content do |content|
508
- print content.text if content.is_a?(Intelligence::MessageContent::Text)
509
- end
501
+ contents = []
502
+ response = request.stream( "Tell me a story about a robot." ) do | request |
503
+ request.receive_result do | result |
504
+ choice = result.choices.first
505
+ contents_fragments = choice.message.contents
506
+ contents.fill( nil, contents.length..(contents_fragments.length - 1) )
507
+
508
+ contents_fragments.each_with_index do | contents_fragment, index |
509
+ if contents_fragment.is_a?( Intelligence::MessageContent::Text )
510
+ # here we need the `|| ''` because the text of the fragment may be nil
511
+ print contents_fragment.text
512
+ else
513
+ contents[ index ] = contents[ index ].nil? ?
514
+ contents_fragment :
515
+ contents[ index ].merge( contents_fragment )
516
+ end
510
517
  end
518
+
511
519
  end
512
520
  end
513
521
  ```
514
522
 
523
+ In the above example we construct an array to receive the content. As the content fragments
524
+ are streamed we will immediatelly output generated text but other types of content ( today
525
+ it could only be instances of `Intelligence::MessageContent::ToolCall' ) are individualy
526
+ combined in the `contents` array. You can simply iterate though the array and then retrieve
527
+ and take action for any of the tool calls.
528
+
529
+ Note also that the `result` will only include a non-nil `end_reason` as the last ( or one
530
+ of the last, `result` instances to be received ).
531
+
532
+ Finally note that the streamed `result` is always a `ChatResult`, never a `ChatErrorResult`.
533
+ If an error occurs, the request itself will fail and you will receive this as part of
534
+ `response.result`.
535
+
515
536
  ## Provider Switching
516
537
 
517
538
  One of Intelligence's most powerful features is the ability to easily switch between providers:
data/intelligence.gemspec CHANGED
@@ -39,6 +39,7 @@ Gem::Specification.new do | spec |
39
39
  spec.add_runtime_dependency 'faraday', '~> 2.7'
40
40
  spec.add_runtime_dependency 'dynamicschema', '~> 1.0.0.beta03'
41
41
  spec.add_runtime_dependency 'mime-types', '~> 3.6'
42
+ spec.add_runtime_dependency 'json-repair', '~> 0.2'
42
43
 
43
44
  spec.add_development_dependency 'rspec', '~> 3.4'
44
45
  spec.add_development_dependency 'debug', '~> 1.9'
@@ -89,15 +89,8 @@ module Intelligence
89
89
  output_tokens: 0
90
90
  }
91
91
 
92
- contents.each do | content |
93
- case content[ :type ]
94
- when :text
95
- content[ :text ] = ''
96
- when :tool_call
97
- content[ :tool_parameters ] = ''
98
- else
99
- content.clear
100
- end
92
+ contents.map! do | content |
93
+ { type: content[ :type ] }
101
94
  end
102
95
 
103
96
  buffer += chunk
@@ -116,7 +109,7 @@ module Intelligence
116
109
  metrics[ :output_tokens ] += data[ 'message' ]&.[]( 'usage' )&.[]( 'output_tokens' ) || 0
117
110
  when 'content_block_start'
118
111
  index = data[ 'index' ]
119
- contents.fill( {}, contents.size, index + 1 ) if contents.size <= index
112
+ contents.fill( {}, contents.size..index ) if contents.size <= index
120
113
  if content_block = data[ 'content_block' ]
121
114
  if content_block[ 'type' ] == 'text'
122
115
  contents[ index ] = {
@@ -134,7 +127,7 @@ module Intelligence
134
127
  end
135
128
  when 'content_block_delta'
136
129
  index = data[ 'index' ]
137
- contents.fill( {}, contents.size, index + 1 ) if contents.size <= index
130
+ contents.fill( {}, contents.size..index ) if contents.size <= index
138
131
  if delta = data[ 'delta' ]
139
132
  if delta[ 'type' ] == 'text_delta'
140
133
  contents[ index ][ :type ] = :text
@@ -142,7 +135,7 @@ module Intelligence
142
135
  elsif delta[ 'type' ] == 'input_json_delta'
143
136
  contents[ index ][ :type ] = :tool_call
144
137
  contents[ index ][ :tool_parameters ] =
145
- ( contents[ index ][ :tool_parameters ] || '' ) + delta[ 'input_json_delta' ]
138
+ ( contents[ index ][ :tool_parameters ] || '' ) + delta[ 'partial_json' ]
146
139
  end
147
140
  end
148
141
  when 'message_delta'
@@ -1,9 +1,9 @@
1
- require_relative 'legacy/adapter'
1
+ require_relative 'generic/adapter'
2
2
 
3
3
  module Intelligence
4
4
  module Cerebras
5
5
 
6
- class Adapter < Legacy::Adapter
6
+ class Adapter < Generic::Adapter
7
7
 
8
8
  chat_request_uri "https://api.cerebras.ai/v1/chat/completions"
9
9
 
@@ -1,10 +1,12 @@
1
1
  require_relative '../../adapter'
2
- require_relative 'chat_methods'
2
+ require_relative 'chat_request_methods'
3
+ require_relative 'chat_response_methods'
3
4
 
4
5
  module Intelligence
5
6
  module Generic
6
7
  class Adapter < Adapter::Base
7
- include ChatMethods
8
+ include ChatRequestMethods
9
+ include ChatResponseMethods
8
10
  end
9
11
  end
10
12
  end
@@ -0,0 +1,221 @@
1
+ module Intelligence
2
+ module Generic
3
+ module ChatRequestMethods
4
+
5
+ module ClassMethods
6
+ def chat_request_uri( uri = nil )
7
+ if uri
8
+ @chat_request_uri = uri
9
+ else
10
+ @chat_request_uri
11
+ end
12
+ end
13
+ end
14
+
15
+ def self.included( base )
16
+ base.extend( ClassMethods )
17
+ end
18
+
19
+ def chat_request_uri( options )
20
+ self.class.chat_request_uri
21
+ end
22
+
23
+ def chat_request_headers( options = nil )
24
+ options = @options.merge( build_options( options ) )
25
+ result = {}
26
+
27
+ key = options[ :key ]
28
+
29
+ raise ArgumentError.new( "An API key is required to build a chat request." ) \
30
+ if key.nil?
31
+
32
+ result[ 'Content-Type' ] = 'application/json'
33
+ result[ 'Authorization' ] = "Bearer #{key}"
34
+
35
+ result
36
+ end
37
+
38
+ def chat_request_body( conversation, options = nil )
39
+ options = @options.merge( build_options( options ) )
40
+
41
+ result = options[ :chat_options ]
42
+ result[ :messages ] = []
43
+
44
+ system_message = chat_request_system_message_attributes( conversation[ :system_message ] )
45
+ result[ :messages ] << system_message if system_message
46
+
47
+ conversation[ :messages ]&.each do | message |
48
+ return nil unless message[ :contents ]&.any?
49
+
50
+ result_message = { role: message[ :role ] }
51
+ result_message_content = []
52
+
53
+ message_contents = message[ :contents ]
54
+
55
+ # tool calls in the open ai api are not content
56
+ tool_calls, message_contents = message_contents.partition do | content |
57
+ content[ :type ] == :tool_call
58
+ end
59
+
60
+ # tool results in the open ai api are not content
61
+ tool_results, message_contents = message_contents.partition do | content |
62
+ content[ :type ] == :tool_result
63
+ end
64
+
65
+ # many vendor api's, especially when hosting text only models, will only accept a single
66
+ # text content item; if the content is only text this will coalece multiple text content
67
+ # items into a single content item
68
+ unless message_contents.any? { | c | c[ :type ] != :text }
69
+ result_message_content = message_contents.map { | c | c[ :text ] || '' }.join( "\n" )
70
+ else
71
+ message_contents&.each do | content |
72
+ result_message_content << chat_request_message_content_attributes( content )
73
+ end
74
+ end
75
+
76
+ if tool_calls.any?
77
+ result_message[ :tool_calls ] = tool_calls.map { | tool_call |
78
+ {
79
+ id: tool_call[ :tool_call_id ],
80
+ type: 'function',
81
+ function: {
82
+ name: tool_call[ :tool_name ],
83
+ arguments: JSON.generate( tool_call[ :tool_parameters ] || {} )
84
+ }
85
+ }
86
+ }
87
+ end
88
+
89
+ result_message[ :content ] = result_message_content
90
+ unless result_message_content.empty? && tool_calls.empty?
91
+ result[ :messages ] << result_message
92
+ end
93
+
94
+ if tool_results.any?
95
+ result[ :messages ].concat( tool_results.map { | tool_result |
96
+ {
97
+ role: :tool,
98
+ tool_call_id: tool_result[ :tool_call_id ],
99
+ content: tool_result[ :tool_result ]
100
+ }
101
+ } )
102
+ end
103
+ end
104
+
105
+ tools_attributes = chat_request_tools_attributes( conversation[ :tools ] )
106
+ result[ :tools ] = tools_attributes if tools_attributes && tools_attributes.length > 0
107
+
108
+ JSON.generate( result )
109
+ end
110
+
111
+ def chat_request_message_content_attributes( content )
112
+ case content[ :type ]
113
+ when :text
114
+ { type: 'text', text: content[ :text ] }
115
+ when :binary
116
+ content_type = content[ :content_type ]
117
+ bytes = content[ :bytes ]
118
+ if content_type && bytes
119
+ mime_type = MIME::Types[ content_type ].first
120
+ if mime_type&.media_type == 'image'
121
+ {
122
+ type: 'image_url',
123
+ image_url: {
124
+ url: "data:#{content_type};base64,#{Base64.strict_encode64( bytes )}".freeze
125
+ }
126
+ }
127
+ else
128
+ raise UnsupportedContentError.new(
129
+ :generic,
130
+ 'only support content of type image/*'
131
+ )
132
+ end
133
+ else
134
+ raise UnsupportedContentError.new(
135
+ :generic,
136
+ 'requires binary content to include content type and ( packed ) bytes'
137
+ )
138
+ end
139
+ when :file
140
+ content_type = content[ :content_type ]
141
+ uri = content[ :uri ]
142
+ if content_type && uri
143
+ mime_type = MIME::Types[ content_type ].first
144
+ if mime_type&.media_type == 'image'
145
+ {
146
+ type: 'image_url',
147
+ image_url: { url: uri }
148
+ }
149
+ else
150
+ raise UnsupportedContentError.new(
151
+ :generic,
152
+ 'only support content of type image/*'
153
+ )
154
+ end
155
+ else
156
+ raise UnsupportedContentError.new(
157
+ :generic,
158
+ 'requires binary content to include content type and ( packed ) bytes'
159
+ )
160
+ end
161
+ end
162
+ end
163
+
164
+ def chat_request_system_message_attributes( system_message )
165
+ return nil if system_message.nil?
166
+
167
+ result = ''
168
+ system_message[ :contents ].each do | content |
169
+ result += content[ :text ] if content[ :type ] == :text
170
+ end
171
+
172
+ result.empty? ? nil : { role: 'system', content: result } if system_message
173
+ end
174
+
175
+ def chat_request_tools_attributes( tools )
176
+ properties_array_to_object = lambda do | properties |
177
+ return nil unless properties&.any?
178
+ object = {}
179
+ required = []
180
+ properties.each do | property |
181
+ name = property.delete( :name )
182
+ required << name if property.delete( :required )
183
+ if property[ :properties ]&.any?
184
+ property_properties, property_required =
185
+ properties_array_to_object.call( property[ :properties ] )
186
+ property[ :properties ] = property_properties
187
+ property[ :required ] = property_required if property_required.any?
188
+ end
189
+ object[ name ] = property
190
+ end
191
+ [ object, required.compact ]
192
+ end
193
+
194
+ tools&.map do | tool |
195
+ function = {
196
+ type: 'function',
197
+ function: {
198
+ name: tool[ :name ],
199
+ description: tool[ :description ],
200
+ }
201
+ }
202
+
203
+ if tool[ :properties ]&.any?
204
+ properties_object, properties_required =
205
+ properties_array_to_object.call( tool[ :properties ] )
206
+ function[ :function ][ :parameters ] = {
207
+ type: 'object',
208
+ properties: properties_object
209
+ }
210
+ function[ :function ][ :parameters ][ :required ] = properties_required \
211
+ if properties_required.any?
212
+ else
213
+ function[ :function ][ :parameters ] = {}
214
+ end
215
+ function
216
+ end
217
+ end
218
+
219
+ end
220
+ end
221
+ end