raif 1.1.0 → 1.2.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.
- checksums.yaml +4 -4
- data/README.md +148 -4
- data/app/assets/builds/raif.css +26 -1
- data/app/assets/stylesheets/raif/loader.scss +27 -1
- data/app/models/raif/concerns/llm_response_parsing.rb +22 -16
- data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +56 -0
- data/app/models/raif/concerns/llms/{bedrock_claude → bedrock}/message_formatting.rb +4 -4
- data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +37 -0
- data/app/models/raif/concerns/llms/message_formatting.rb +7 -6
- data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +138 -0
- data/app/models/raif/concerns/llms/{open_ai → open_ai_completions}/message_formatting.rb +1 -1
- data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +26 -0
- data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +43 -0
- data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +42 -0
- data/app/models/raif/conversation.rb +17 -4
- data/app/models/raif/conversation_entry.rb +18 -2
- data/app/models/raif/embedding_models/{bedrock_titan.rb → bedrock.rb} +2 -2
- data/app/models/raif/llm.rb +73 -7
- data/app/models/raif/llms/anthropic.rb +56 -36
- data/app/models/raif/llms/{bedrock_claude.rb → bedrock.rb} +62 -45
- data/app/models/raif/llms/open_ai_base.rb +66 -0
- data/app/models/raif/llms/open_ai_completions.rb +100 -0
- data/app/models/raif/llms/open_ai_responses.rb +144 -0
- data/app/models/raif/llms/open_router.rb +38 -43
- data/app/models/raif/model_completion.rb +2 -0
- data/app/models/raif/model_tool.rb +4 -0
- data/app/models/raif/model_tools/provider_managed/base.rb +9 -0
- data/app/models/raif/model_tools/provider_managed/code_execution.rb +5 -0
- data/app/models/raif/model_tools/provider_managed/image_generation.rb +5 -0
- data/app/models/raif/model_tools/provider_managed/web_search.rb +5 -0
- data/app/models/raif/streaming_responses/anthropic.rb +63 -0
- data/app/models/raif/streaming_responses/bedrock.rb +89 -0
- data/app/models/raif/streaming_responses/open_ai_completions.rb +76 -0
- data/app/models/raif/streaming_responses/open_ai_responses.rb +54 -0
- data/app/views/raif/admin/conversations/_conversation_entry.html.erb +48 -0
- data/app/views/raif/admin/conversations/show.html.erb +1 -1
- data/app/views/raif/admin/model_completions/_model_completion.html.erb +7 -0
- data/app/views/raif/admin/model_completions/index.html.erb +1 -0
- data/app/views/raif/admin/model_completions/show.html.erb +28 -0
- data/app/views/raif/conversation_entries/_citations.html.erb +9 -0
- data/app/views/raif/conversation_entries/_conversation_entry.html.erb +5 -1
- data/app/views/raif/conversation_entries/_message.html.erb +4 -0
- data/config/locales/admin.en.yml +2 -0
- data/config/locales/en.yml +22 -0
- data/db/migrate/20250224234252_create_raif_tables.rb +1 -1
- data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +1 -1
- data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +1 -1
- data/db/migrate/20250424232946_add_created_at_indexes.rb +1 -1
- data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +1 -1
- data/db/migrate/20250527213016_add_response_id_and_response_array_to_model_completions.rb +14 -0
- data/db/migrate/20250603140622_add_citations_to_raif_model_completions.rb +13 -0
- data/db/migrate/20250603202013_add_stream_response_to_raif_model_completions.rb +7 -0
- data/lib/generators/raif/conversation/templates/conversation.rb.tt +3 -3
- data/lib/generators/raif/install/templates/initializer.rb +14 -2
- data/lib/raif/configuration.rb +27 -5
- data/lib/raif/embedding_model_registry.rb +1 -1
- data/lib/raif/engine.rb +25 -9
- data/lib/raif/errors/streaming_error.rb +18 -0
- data/lib/raif/errors.rb +1 -0
- data/lib/raif/llm_registry.rb +157 -47
- data/lib/raif/migration_checker.rb +74 -0
- data/lib/raif/utils/html_fragment_processor.rb +169 -0
- data/lib/raif/utils.rb +1 -0
- data/lib/raif/version.rb +1 -1
- data/lib/raif.rb +2 -0
- metadata +45 -8
- data/app/models/raif/llms/open_ai.rb +0 -256
@@ -0,0 +1,169 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Utility class for processing HTML fragments with various cleaning and transformation operations.
|
4
|
+
#
|
5
|
+
# This class provides methods for sanitizing HTML content, converting markdown links to HTML,
|
6
|
+
# processing existing HTML links (adding target="_blank", stripping tracking parameters),
|
7
|
+
# and removing tracking parameters from URLs.
|
8
|
+
class Raif::Utils::HtmlFragmentProcessor
|
9
|
+
# List of common tracking parameters to remove from URLs
|
10
|
+
TRACKING_PARAMS = %w[
|
11
|
+
utm_source
|
12
|
+
utm_medium
|
13
|
+
utm_campaign
|
14
|
+
utm_term
|
15
|
+
utm_content
|
16
|
+
utm_id
|
17
|
+
]
|
18
|
+
|
19
|
+
class << self
|
20
|
+
# Cleans and sanitizes an HTML fragment by removing empty text nodes and dangerous content.
|
21
|
+
#
|
22
|
+
# @param html [String, Nokogiri::HTML::DocumentFragment] The HTML content to clean
|
23
|
+
# @param allowed_tags [Array<String>, nil] Array of allowed HTML tags. Defaults to Rails HTML5 safe list
|
24
|
+
# @param allowed_attributes [Array<String>, nil] Array of allowed HTML attributes. Defaults to Rails HTML5 safe list
|
25
|
+
# @return [String] Cleaned and sanitized HTML string
|
26
|
+
#
|
27
|
+
# @example
|
28
|
+
# clean_html_fragment("<script>alert('xss')</script><p>Safe content</p>")
|
29
|
+
# # => "<p>Safe content</p>"
|
30
|
+
#
|
31
|
+
# @example With custom allowed tags
|
32
|
+
# clean_html_fragment("<p>Para</p><div>Div</div>", allowed_tags: %w[p])
|
33
|
+
# # => "<p>Para</p>Div"
|
34
|
+
def clean_html_fragment(html, allowed_tags: nil, allowed_attributes: nil)
|
35
|
+
fragment = html.is_a?(Nokogiri::HTML::DocumentFragment) ? html : Nokogiri::HTML.fragment(html)
|
36
|
+
|
37
|
+
fragment.traverse do |node|
|
38
|
+
if node.text? && node.text.strip.empty?
|
39
|
+
node.remove
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
allowed_tags = allowed_tags.presence || Rails::HTML5::SafeListSanitizer.allowed_tags
|
44
|
+
allowed_attributes = allowed_attributes.presence || Rails::HTML5::SafeListSanitizer.allowed_attributes
|
45
|
+
|
46
|
+
ActionController::Base.helpers.sanitize(fragment.to_html, tags: allowed_tags, attributes: allowed_attributes).strip
|
47
|
+
end
|
48
|
+
|
49
|
+
# Converts markdown-style links to HTML anchor tags with target="_blank" and rel="noopener".
|
50
|
+
#
|
51
|
+
# Converts [text](url) format to <a href="url" target="_blank" rel="noopener">text</a>.
|
52
|
+
# Also strips tracking parameters from the URLs.
|
53
|
+
#
|
54
|
+
# @param text [String] The text content that may contain markdown links
|
55
|
+
# @return [String] HTML with markdown links converted to anchor tags
|
56
|
+
#
|
57
|
+
# @example
|
58
|
+
# convert_markdown_links_to_html("Check out [Google](https://google.com) for search.")
|
59
|
+
# # => 'Check out <a href="https://google.com" target="_blank" rel="noopener">Google</a> for search.'
|
60
|
+
#
|
61
|
+
# @example With tracking parameters
|
62
|
+
# convert_markdown_links_to_html("[Example](https://example.com?utm_source=test¶m=keep)")
|
63
|
+
# # => '<a href="https://example.com?param=keep" target="_blank" rel="noopener">Example</a>'
|
64
|
+
def convert_markdown_links_to_html(text)
|
65
|
+
# Convert markdown links [text](url) to HTML links <a href="url" target="_blank" rel="noopener">text</a>
|
66
|
+
text.gsub(/\[([^\]]*)\]\(([^)]+)\)/) do |_match|
|
67
|
+
text = ::Regexp.last_match(1)
|
68
|
+
url = ::Regexp.last_match(2)
|
69
|
+
clean_url = strip_tracking_parameters(url)
|
70
|
+
%(<a href="#{CGI.escapeHTML(clean_url)}" target="_blank" rel="noopener">#{CGI.escapeHTML(text)}</a>)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Processes existing HTML links by optionally adding target="_blank" and stripping tracking parameters.
|
75
|
+
#
|
76
|
+
# This method provides fine-grained control over link processing with configurable options
|
77
|
+
# for both target="_blank" addition and tracking parameter removal.
|
78
|
+
#
|
79
|
+
# @param html [String, Nokogiri::HTML::DocumentFragment] The HTML content containing links to process
|
80
|
+
# @param add_target_blank [Boolean] Whether to add target="_blank" and rel="noopener" to links (required)
|
81
|
+
# @param strip_tracking_parameters [Boolean] Whether to remove tracking parameters from URLs (required)
|
82
|
+
# @return [String] Processed HTML with modified links
|
83
|
+
#
|
84
|
+
# @example Default behavior (adds target="_blank" and strips tracking params)
|
85
|
+
# process_links('<a href="https://example.com?utm_source=test">Link</a>', add_target_blank: true, strip_tracking_parameters: true)
|
86
|
+
# # => '<a href="https://example.com" target="_blank" rel="noopener">Link</a>'
|
87
|
+
#
|
88
|
+
# @example Only strip tracking parameters
|
89
|
+
# process_links(html, add_target_blank: false, strip_tracking_parameters: true)
|
90
|
+
# # => '<a href="https://example.com">Link</a>'
|
91
|
+
#
|
92
|
+
# @example Only add target="_blank"
|
93
|
+
# process_links(html, add_target_blank: true, strip_tracking_parameters: false)
|
94
|
+
# # => '<a href="https://example.com?utm_source=test" target="_blank" rel="noopener">Link</a>'
|
95
|
+
#
|
96
|
+
# @example No processing
|
97
|
+
# process_links(html, add_target_blank: false, strip_tracking_parameters: false)
|
98
|
+
# # => Original HTML unchanged
|
99
|
+
def process_links(html, add_target_blank:, strip_tracking_parameters:)
|
100
|
+
fragment = html.is_a?(Nokogiri::HTML::DocumentFragment) ? html : Nokogiri::HTML.fragment(html)
|
101
|
+
|
102
|
+
fragment.css("a").each do |link|
|
103
|
+
if add_target_blank
|
104
|
+
link["target"] = "_blank"
|
105
|
+
link["rel"] = "noopener"
|
106
|
+
end
|
107
|
+
|
108
|
+
if strip_tracking_parameters
|
109
|
+
link["href"] = strip_tracking_parameters(link["href"])
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
fragment.to_html
|
114
|
+
end
|
115
|
+
|
116
|
+
# Removes tracking parameters (UTM parameters) from a URL.
|
117
|
+
#
|
118
|
+
# Preserves all non-tracking query parameters and handles various URL formats including
|
119
|
+
# relative URLs, absolute URLs, and malformed URLs gracefully.
|
120
|
+
#
|
121
|
+
# @param url [String] The URL to clean
|
122
|
+
# @return [String] URL with tracking parameters removed, or original URL if parsing fails
|
123
|
+
#
|
124
|
+
# @example
|
125
|
+
# strip_tracking_parameters("https://example.com?utm_source=google&page=1")
|
126
|
+
# # => "https://example.com?page=1"
|
127
|
+
#
|
128
|
+
# @example Removes all tracking parameters
|
129
|
+
# strip_tracking_parameters("https://example.com?utm_source=test&utm_medium=cpc")
|
130
|
+
# # => "https://example.com"
|
131
|
+
#
|
132
|
+
# @example Preserves fragments
|
133
|
+
# strip_tracking_parameters("https://example.com?utm_source=test&page=1#section")
|
134
|
+
# # => "https://example.com?page=1#section"
|
135
|
+
#
|
136
|
+
# @example Handles relative URLs
|
137
|
+
# strip_tracking_parameters("/path?utm_source=test¶m=keep")
|
138
|
+
# # => "/path?param=keep"
|
139
|
+
def strip_tracking_parameters(url)
|
140
|
+
return url unless url.include?("?")
|
141
|
+
|
142
|
+
begin
|
143
|
+
uri = URI.parse(url)
|
144
|
+
return url unless uri.query
|
145
|
+
|
146
|
+
# Only process URLs that have a valid scheme and host, or are relative URLs
|
147
|
+
unless uri.scheme || url.start_with?("/", "#")
|
148
|
+
return url
|
149
|
+
end
|
150
|
+
|
151
|
+
# Parse query parameters and filter out tracking ones
|
152
|
+
params = URI.decode_www_form(uri.query)
|
153
|
+
clean_params = params.reject { |param, _| TRACKING_PARAMS.include?(param.downcase) }
|
154
|
+
|
155
|
+
# Rebuild the URL
|
156
|
+
uri.query = if clean_params.empty?
|
157
|
+
nil
|
158
|
+
else
|
159
|
+
URI.encode_www_form(clean_params)
|
160
|
+
end
|
161
|
+
|
162
|
+
uri.to_s
|
163
|
+
rescue URI::InvalidURIError
|
164
|
+
# If URL parsing fails, return the original URL
|
165
|
+
url
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
data/lib/raif/utils.rb
CHANGED
data/lib/raif/version.rb
CHANGED
data/lib/raif.rb
CHANGED
@@ -9,8 +9,10 @@ require "raif/utils"
|
|
9
9
|
require "raif/llm_registry"
|
10
10
|
require "raif/embedding_model_registry"
|
11
11
|
require "raif/json_schema_builder"
|
12
|
+
require "raif/migration_checker"
|
12
13
|
|
13
14
|
require "faraday"
|
15
|
+
require "event_stream_parser"
|
14
16
|
require "json-schema"
|
15
17
|
require "loofah"
|
16
18
|
require "pagy"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: raif
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ben Roesch
|
8
8
|
- Brian Leslie
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: aws-sdk-bedrockruntime
|
@@ -24,6 +24,20 @@ dependencies:
|
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: event_stream_parser
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.0'
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: faraday
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -175,18 +189,26 @@ files:
|
|
175
189
|
- app/models/raif/concerns/llm_response_parsing.rb
|
176
190
|
- app/models/raif/concerns/llm_temperature.rb
|
177
191
|
- app/models/raif/concerns/llms/anthropic/message_formatting.rb
|
178
|
-
- app/models/raif/concerns/llms/
|
192
|
+
- app/models/raif/concerns/llms/anthropic/tool_formatting.rb
|
193
|
+
- app/models/raif/concerns/llms/bedrock/message_formatting.rb
|
194
|
+
- app/models/raif/concerns/llms/bedrock/tool_formatting.rb
|
179
195
|
- app/models/raif/concerns/llms/message_formatting.rb
|
180
|
-
- app/models/raif/concerns/llms/open_ai/
|
196
|
+
- app/models/raif/concerns/llms/open_ai/json_schema_validation.rb
|
197
|
+
- app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb
|
198
|
+
- app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb
|
199
|
+
- app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb
|
200
|
+
- app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb
|
181
201
|
- app/models/raif/conversation.rb
|
182
202
|
- app/models/raif/conversation_entry.rb
|
183
203
|
- app/models/raif/embedding_model.rb
|
184
|
-
- app/models/raif/embedding_models/
|
204
|
+
- app/models/raif/embedding_models/bedrock.rb
|
185
205
|
- app/models/raif/embedding_models/open_ai.rb
|
186
206
|
- app/models/raif/llm.rb
|
187
207
|
- app/models/raif/llms/anthropic.rb
|
188
|
-
- app/models/raif/llms/
|
189
|
-
- app/models/raif/llms/
|
208
|
+
- app/models/raif/llms/bedrock.rb
|
209
|
+
- app/models/raif/llms/open_ai_base.rb
|
210
|
+
- app/models/raif/llms/open_ai_completions.rb
|
211
|
+
- app/models/raif/llms/open_ai_responses.rb
|
190
212
|
- app/models/raif/llms/open_router.rb
|
191
213
|
- app/models/raif/model_completion.rb
|
192
214
|
- app/models/raif/model_file_input.rb
|
@@ -195,7 +217,15 @@ files:
|
|
195
217
|
- app/models/raif/model_tool_invocation.rb
|
196
218
|
- app/models/raif/model_tools/agent_final_answer.rb
|
197
219
|
- app/models/raif/model_tools/fetch_url.rb
|
220
|
+
- app/models/raif/model_tools/provider_managed/base.rb
|
221
|
+
- app/models/raif/model_tools/provider_managed/code_execution.rb
|
222
|
+
- app/models/raif/model_tools/provider_managed/image_generation.rb
|
223
|
+
- app/models/raif/model_tools/provider_managed/web_search.rb
|
198
224
|
- app/models/raif/model_tools/wikipedia_search.rb
|
225
|
+
- app/models/raif/streaming_responses/anthropic.rb
|
226
|
+
- app/models/raif/streaming_responses/bedrock.rb
|
227
|
+
- app/models/raif/streaming_responses/open_ai_completions.rb
|
228
|
+
- app/models/raif/streaming_responses/open_ai_responses.rb
|
199
229
|
- app/models/raif/task.rb
|
200
230
|
- app/models/raif/user_tool_invocation.rb
|
201
231
|
- app/views/layouts/raif/admin.html.erb
|
@@ -218,6 +248,7 @@ files:
|
|
218
248
|
- app/views/raif/admin/tasks/_task.html.erb
|
219
249
|
- app/views/raif/admin/tasks/index.html.erb
|
220
250
|
- app/views/raif/admin/tasks/show.html.erb
|
251
|
+
- app/views/raif/conversation_entries/_citations.html.erb
|
221
252
|
- app/views/raif/conversation_entries/_conversation_entry.html.erb
|
222
253
|
- app/views/raif/conversation_entries/_form.html.erb
|
223
254
|
- app/views/raif/conversation_entries/_form_with_available_tools.html.erb
|
@@ -242,6 +273,9 @@ files:
|
|
242
273
|
- db/migrate/20250424232946_add_created_at_indexes.rb
|
243
274
|
- db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb
|
244
275
|
- db/migrate/20250507155314_add_retry_count_to_raif_model_completions.rb
|
276
|
+
- db/migrate/20250527213016_add_response_id_and_response_array_to_model_completions.rb
|
277
|
+
- db/migrate/20250603140622_add_citations_to_raif_model_completions.rb
|
278
|
+
- db/migrate/20250603202013_add_stream_response_to_raif_model_completions.rb
|
245
279
|
- lib/generators/raif/agent/agent_generator.rb
|
246
280
|
- lib/generators/raif/agent/templates/agent.rb.tt
|
247
281
|
- lib/generators/raif/agent/templates/application_agent.rb.tt
|
@@ -268,12 +302,15 @@ files:
|
|
268
302
|
- lib/raif/errors/invalid_model_image_input_error.rb
|
269
303
|
- lib/raif/errors/invalid_user_tool_type_error.rb
|
270
304
|
- lib/raif/errors/open_ai/json_schema_error.rb
|
305
|
+
- lib/raif/errors/streaming_error.rb
|
271
306
|
- lib/raif/errors/unsupported_feature_error.rb
|
272
307
|
- lib/raif/json_schema_builder.rb
|
273
308
|
- lib/raif/languages.rb
|
274
309
|
- lib/raif/llm_registry.rb
|
310
|
+
- lib/raif/migration_checker.rb
|
275
311
|
- lib/raif/rspec.rb
|
276
312
|
- lib/raif/utils.rb
|
313
|
+
- lib/raif/utils/html_fragment_processor.rb
|
277
314
|
- lib/raif/utils/html_to_markdown_converter.rb
|
278
315
|
- lib/raif/utils/readable_content_extractor.rb
|
279
316
|
- lib/raif/version.rb
|
@@ -300,7 +337,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
300
337
|
- !ruby/object:Gem::Version
|
301
338
|
version: '0'
|
302
339
|
requirements: []
|
303
|
-
rubygems_version: 3.6.
|
340
|
+
rubygems_version: 3.6.7
|
304
341
|
specification_version: 4
|
305
342
|
summary: Raif (Ruby AI Framework) is a Rails engine that helps you add AI-powered
|
306
343
|
features to your Rails apps, such as tasks, conversations, and agents.
|
@@ -1,256 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class Raif::Llms::OpenAi < Raif::Llm
|
4
|
-
include Raif::Concerns::Llms::OpenAi::MessageFormatting
|
5
|
-
|
6
|
-
def perform_model_completion!(model_completion)
|
7
|
-
model_completion.temperature ||= default_temperature
|
8
|
-
parameters = build_request_parameters(model_completion)
|
9
|
-
|
10
|
-
response = connection.post("chat/completions") do |req|
|
11
|
-
req.body = parameters
|
12
|
-
end
|
13
|
-
|
14
|
-
response_json = response.body
|
15
|
-
|
16
|
-
model_completion.update!(
|
17
|
-
response_tool_calls: extract_response_tool_calls(response_json),
|
18
|
-
raw_response: response_json.dig("choices", 0, "message", "content"),
|
19
|
-
completion_tokens: response_json.dig("usage", "completion_tokens"),
|
20
|
-
prompt_tokens: response_json.dig("usage", "prompt_tokens"),
|
21
|
-
total_tokens: response_json.dig("usage", "total_tokens"),
|
22
|
-
response_format_parameter: parameters.dig(:response_format, :type)
|
23
|
-
)
|
24
|
-
|
25
|
-
model_completion
|
26
|
-
end
|
27
|
-
|
28
|
-
def connection
|
29
|
-
@connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
|
30
|
-
f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
|
31
|
-
f.request :json
|
32
|
-
f.response :json
|
33
|
-
f.response :raise_error
|
34
|
-
end
|
35
|
-
end
|
36
|
-
|
37
|
-
def validate_json_schema!(schema)
|
38
|
-
return if schema.blank?
|
39
|
-
|
40
|
-
errors = []
|
41
|
-
|
42
|
-
# Check if schema is present
|
43
|
-
if schema.blank?
|
44
|
-
errors << "JSON schema must include a 'schema' property"
|
45
|
-
else
|
46
|
-
# Check root object type
|
47
|
-
if schema[:type] != "object" && !schema.key?(:properties)
|
48
|
-
errors << "Root schema must be of type 'object' with 'properties'"
|
49
|
-
end
|
50
|
-
|
51
|
-
# Check all objects in the schema recursively
|
52
|
-
validate_object_properties(schema, errors)
|
53
|
-
|
54
|
-
# Check properties count (max 100 total)
|
55
|
-
validate_properties_count(schema, errors)
|
56
|
-
|
57
|
-
# Check nesting depth (max 5 levels)
|
58
|
-
validate_nesting_depth(schema, errors)
|
59
|
-
|
60
|
-
# Check for unsupported anyOf at root level
|
61
|
-
if schema[:anyOf].present? && schema[:properties].blank?
|
62
|
-
errors << "Root objects cannot be of type 'anyOf'"
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
# Raise error if any validation issues found
|
67
|
-
if errors.any?
|
68
|
-
error_message = "Invalid JSON schema for OpenAI structured outputs: #{errors.join("; ")}\nSchema was: #{schema.inspect}"
|
69
|
-
raise Raif::Errors::OpenAi::JsonSchemaError, error_message
|
70
|
-
else
|
71
|
-
true
|
72
|
-
end
|
73
|
-
end
|
74
|
-
|
75
|
-
private
|
76
|
-
|
77
|
-
def extract_response_tool_calls(resp)
|
78
|
-
return if resp.dig("choices", 0, "message", "tool_calls").blank?
|
79
|
-
|
80
|
-
resp.dig("choices", 0, "message", "tool_calls").map do |tool_call|
|
81
|
-
{
|
82
|
-
"name" => tool_call["function"]["name"],
|
83
|
-
"arguments" => JSON.parse(tool_call["function"]["arguments"])
|
84
|
-
}
|
85
|
-
end
|
86
|
-
end
|
87
|
-
|
88
|
-
def build_request_parameters(model_completion)
|
89
|
-
formatted_system_prompt = model_completion.system_prompt.to_s.strip
|
90
|
-
|
91
|
-
# If the response format is JSON, we need to include "as json" in the system prompt.
|
92
|
-
# OpenAI requires this and will throw an error if it's not included.
|
93
|
-
if model_completion.response_format_json?
|
94
|
-
# Ensure system prompt ends with a period if not empty
|
95
|
-
if formatted_system_prompt.present? && !formatted_system_prompt.end_with?(".", "?", "!")
|
96
|
-
formatted_system_prompt += "."
|
97
|
-
end
|
98
|
-
formatted_system_prompt += " Return your response as JSON."
|
99
|
-
formatted_system_prompt.strip!
|
100
|
-
end
|
101
|
-
|
102
|
-
messages = model_completion.messages
|
103
|
-
messages_with_system = if !formatted_system_prompt.empty?
|
104
|
-
[{ "role" => "system", "content" => formatted_system_prompt }] + messages
|
105
|
-
else
|
106
|
-
messages
|
107
|
-
end
|
108
|
-
|
109
|
-
parameters = {
|
110
|
-
model: api_name,
|
111
|
-
messages: messages_with_system,
|
112
|
-
temperature: model_completion.temperature.to_f
|
113
|
-
}
|
114
|
-
|
115
|
-
# If the LLM supports native tool use and there are available tools, add them to the parameters
|
116
|
-
if supports_native_tool_use? && model_completion.available_model_tools.any?
|
117
|
-
parameters[:tools] = model_completion.available_model_tools_map.map do |_tool_name, tool|
|
118
|
-
validate_json_schema!(tool.tool_arguments_schema)
|
119
|
-
|
120
|
-
{
|
121
|
-
type: "function",
|
122
|
-
function: {
|
123
|
-
name: tool.tool_name,
|
124
|
-
description: tool.tool_description,
|
125
|
-
parameters: tool.tool_arguments_schema
|
126
|
-
}
|
127
|
-
}
|
128
|
-
end
|
129
|
-
end
|
130
|
-
|
131
|
-
# Add response format if needed
|
132
|
-
response_format = determine_response_format(model_completion)
|
133
|
-
parameters[:response_format] = response_format if response_format
|
134
|
-
|
135
|
-
parameters
|
136
|
-
end
|
137
|
-
|
138
|
-
def determine_response_format(model_completion)
|
139
|
-
# Only configure response format for JSON outputs
|
140
|
-
return unless model_completion.response_format_json?
|
141
|
-
|
142
|
-
if model_completion.json_response_schema.present? && supports_structured_outputs?
|
143
|
-
validate_json_schema!(model_completion.json_response_schema)
|
144
|
-
|
145
|
-
{
|
146
|
-
type: "json_schema",
|
147
|
-
json_schema: {
|
148
|
-
name: "json_response_schema",
|
149
|
-
strict: true,
|
150
|
-
schema: model_completion.json_response_schema
|
151
|
-
}
|
152
|
-
}
|
153
|
-
else
|
154
|
-
# Default JSON mode for OpenAI models that don't support structured outputs or no schema is provided
|
155
|
-
{ type: "json_object" }
|
156
|
-
end
|
157
|
-
end
|
158
|
-
|
159
|
-
def supports_structured_outputs?
|
160
|
-
# Not all OpenAI models support structured outputs:
|
161
|
-
# https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat#supported-models
|
162
|
-
provider_settings.key?(:supports_structured_outputs) ? provider_settings[:supports_structured_outputs] : true
|
163
|
-
end
|
164
|
-
|
165
|
-
def validate_object_properties(schema, errors)
|
166
|
-
return unless schema.is_a?(Hash)
|
167
|
-
|
168
|
-
# Check if the current schema is an object and validate additionalProperties and required fields
|
169
|
-
if schema[:type] == "object"
|
170
|
-
if schema[:additionalProperties] != false
|
171
|
-
errors << "All objects must have 'additionalProperties' set to false"
|
172
|
-
end
|
173
|
-
|
174
|
-
# Check that all properties are required
|
175
|
-
if schema[:properties].is_a?(Hash) && schema[:properties].any?
|
176
|
-
property_keys = schema[:properties].keys
|
177
|
-
required_fields = schema[:required] || []
|
178
|
-
|
179
|
-
if required_fields.sort != property_keys.map(&:to_s).sort
|
180
|
-
errors << "All object properties must be listed in the 'required' array"
|
181
|
-
end
|
182
|
-
end
|
183
|
-
end
|
184
|
-
|
185
|
-
# Check if the current schema is an object and validate additionalProperties
|
186
|
-
if schema[:type] == "object"
|
187
|
-
if schema[:additionalProperties] != false
|
188
|
-
errors << "All objects must have 'additionalProperties' set to false"
|
189
|
-
end
|
190
|
-
|
191
|
-
# Check properties of the object recursively
|
192
|
-
if schema[:properties].is_a?(Hash)
|
193
|
-
schema[:properties].each_value do |property|
|
194
|
-
validate_object_properties(property, errors)
|
195
|
-
end
|
196
|
-
end
|
197
|
-
end
|
198
|
-
|
199
|
-
# Check array items
|
200
|
-
if schema[:type] == "array" && schema[:items].is_a?(Hash)
|
201
|
-
validate_object_properties(schema[:items], errors)
|
202
|
-
end
|
203
|
-
|
204
|
-
# Check anyOf
|
205
|
-
if schema[:anyOf].is_a?(Array)
|
206
|
-
schema[:anyOf].each do |option|
|
207
|
-
validate_object_properties(option, errors)
|
208
|
-
end
|
209
|
-
end
|
210
|
-
end
|
211
|
-
|
212
|
-
def validate_properties_count(schema, errors, count = 0)
|
213
|
-
return count unless schema.is_a?(Hash)
|
214
|
-
|
215
|
-
if schema[:properties].is_a?(Hash)
|
216
|
-
count += schema[:properties].size
|
217
|
-
|
218
|
-
if count > 100
|
219
|
-
errors << "Schema exceeds maximum of 100 total object properties"
|
220
|
-
return count
|
221
|
-
end
|
222
|
-
|
223
|
-
# Check nested properties
|
224
|
-
schema[:properties].each_value do |property|
|
225
|
-
count = validate_properties_count(property, errors, count)
|
226
|
-
end
|
227
|
-
end
|
228
|
-
|
229
|
-
# Check array items
|
230
|
-
if schema[:type] == "array" && schema[:items].is_a?(Hash)
|
231
|
-
count = validate_properties_count(schema[:items], errors, count)
|
232
|
-
end
|
233
|
-
|
234
|
-
count
|
235
|
-
end
|
236
|
-
|
237
|
-
def validate_nesting_depth(schema, errors, depth = 1)
|
238
|
-
return unless schema.is_a?(Hash)
|
239
|
-
|
240
|
-
if depth > 5
|
241
|
-
errors << "Schema exceeds maximum nesting depth of 5 levels"
|
242
|
-
return
|
243
|
-
end
|
244
|
-
|
245
|
-
if schema[:properties].is_a?(Hash)
|
246
|
-
schema[:properties].each_value do |property|
|
247
|
-
validate_nesting_depth(property, errors, depth + 1)
|
248
|
-
end
|
249
|
-
end
|
250
|
-
|
251
|
-
# Check array items
|
252
|
-
if schema[:type] == "array" && schema[:items].is_a?(Hash)
|
253
|
-
validate_nesting_depth(schema[:items], errors, depth + 1)
|
254
|
-
end
|
255
|
-
end
|
256
|
-
end
|