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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -4
  3. data/app/assets/builds/raif.css +26 -1
  4. data/app/assets/stylesheets/raif/loader.scss +27 -1
  5. data/app/models/raif/concerns/llm_response_parsing.rb +22 -16
  6. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +56 -0
  7. data/app/models/raif/concerns/llms/{bedrock_claude → bedrock}/message_formatting.rb +4 -4
  8. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +37 -0
  9. data/app/models/raif/concerns/llms/message_formatting.rb +7 -6
  10. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +138 -0
  11. data/app/models/raif/concerns/llms/{open_ai → open_ai_completions}/message_formatting.rb +1 -1
  12. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +26 -0
  13. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +43 -0
  14. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +42 -0
  15. data/app/models/raif/conversation.rb +17 -4
  16. data/app/models/raif/conversation_entry.rb +18 -2
  17. data/app/models/raif/embedding_models/{bedrock_titan.rb → bedrock.rb} +2 -2
  18. data/app/models/raif/llm.rb +73 -7
  19. data/app/models/raif/llms/anthropic.rb +56 -36
  20. data/app/models/raif/llms/{bedrock_claude.rb → bedrock.rb} +62 -45
  21. data/app/models/raif/llms/open_ai_base.rb +66 -0
  22. data/app/models/raif/llms/open_ai_completions.rb +100 -0
  23. data/app/models/raif/llms/open_ai_responses.rb +144 -0
  24. data/app/models/raif/llms/open_router.rb +38 -43
  25. data/app/models/raif/model_completion.rb +2 -0
  26. data/app/models/raif/model_tool.rb +4 -0
  27. data/app/models/raif/model_tools/provider_managed/base.rb +9 -0
  28. data/app/models/raif/model_tools/provider_managed/code_execution.rb +5 -0
  29. data/app/models/raif/model_tools/provider_managed/image_generation.rb +5 -0
  30. data/app/models/raif/model_tools/provider_managed/web_search.rb +5 -0
  31. data/app/models/raif/streaming_responses/anthropic.rb +63 -0
  32. data/app/models/raif/streaming_responses/bedrock.rb +89 -0
  33. data/app/models/raif/streaming_responses/open_ai_completions.rb +76 -0
  34. data/app/models/raif/streaming_responses/open_ai_responses.rb +54 -0
  35. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +48 -0
  36. data/app/views/raif/admin/conversations/show.html.erb +1 -1
  37. data/app/views/raif/admin/model_completions/_model_completion.html.erb +7 -0
  38. data/app/views/raif/admin/model_completions/index.html.erb +1 -0
  39. data/app/views/raif/admin/model_completions/show.html.erb +28 -0
  40. data/app/views/raif/conversation_entries/_citations.html.erb +9 -0
  41. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +5 -1
  42. data/app/views/raif/conversation_entries/_message.html.erb +4 -0
  43. data/config/locales/admin.en.yml +2 -0
  44. data/config/locales/en.yml +22 -0
  45. data/db/migrate/20250224234252_create_raif_tables.rb +1 -1
  46. data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +1 -1
  47. data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +1 -1
  48. data/db/migrate/20250424232946_add_created_at_indexes.rb +1 -1
  49. data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +1 -1
  50. data/db/migrate/20250527213016_add_response_id_and_response_array_to_model_completions.rb +14 -0
  51. data/db/migrate/20250603140622_add_citations_to_raif_model_completions.rb +13 -0
  52. data/db/migrate/20250603202013_add_stream_response_to_raif_model_completions.rb +7 -0
  53. data/lib/generators/raif/conversation/templates/conversation.rb.tt +3 -3
  54. data/lib/generators/raif/install/templates/initializer.rb +14 -2
  55. data/lib/raif/configuration.rb +27 -5
  56. data/lib/raif/embedding_model_registry.rb +1 -1
  57. data/lib/raif/engine.rb +25 -9
  58. data/lib/raif/errors/streaming_error.rb +18 -0
  59. data/lib/raif/errors.rb +1 -0
  60. data/lib/raif/llm_registry.rb +157 -47
  61. data/lib/raif/migration_checker.rb +74 -0
  62. data/lib/raif/utils/html_fragment_processor.rb +169 -0
  63. data/lib/raif/utils.rb +1 -0
  64. data/lib/raif/version.rb +1 -1
  65. data/lib/raif.rb +2 -0
  66. metadata +45 -8
  67. 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&param=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&param=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
@@ -3,4 +3,5 @@
3
3
  module Raif::Utils
4
4
  require "raif/utils/readable_content_extractor"
5
5
  require "raif/utils/html_to_markdown_converter"
6
+ require "raif/utils/html_fragment_processor"
6
7
  end
data/lib/raif/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raif
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
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.1.0
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: 2025-05-23 00:00:00.000000000 Z
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/bedrock_claude/message_formatting.rb
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/message_formatting.rb
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/bedrock_titan.rb
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/bedrock_claude.rb
189
- - app/models/raif/llms/open_ai.rb
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.2
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