raif 1.0.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +346 -43
  3. data/app/assets/builds/raif.css +26 -1
  4. data/app/assets/stylesheets/raif/admin/stats.scss +12 -0
  5. data/app/assets/stylesheets/raif/loader.scss +27 -1
  6. data/app/controllers/raif/admin/application_controller.rb +14 -0
  7. data/app/controllers/raif/admin/stats/tasks_controller.rb +25 -0
  8. data/app/controllers/raif/admin/stats_controller.rb +19 -0
  9. data/app/controllers/raif/admin/tasks_controller.rb +18 -2
  10. data/app/controllers/raif/conversations_controller.rb +5 -1
  11. data/app/models/raif/agent.rb +11 -9
  12. data/app/models/raif/agents/native_tool_calling_agent.rb +11 -1
  13. data/app/models/raif/agents/re_act_agent.rb +6 -0
  14. data/app/models/raif/concerns/has_available_model_tools.rb +1 -1
  15. data/app/models/raif/concerns/json_schema_definition.rb +28 -0
  16. data/app/models/raif/concerns/llm_response_parsing.rb +42 -14
  17. data/app/models/raif/concerns/llm_temperature.rb +17 -0
  18. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +51 -0
  19. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +56 -0
  20. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +70 -0
  21. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +37 -0
  22. data/app/models/raif/concerns/llms/message_formatting.rb +42 -0
  23. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +138 -0
  24. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +41 -0
  25. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +26 -0
  26. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +43 -0
  27. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +42 -0
  28. data/app/models/raif/conversation.rb +28 -7
  29. data/app/models/raif/conversation_entry.rb +40 -8
  30. data/app/models/raif/embedding_model.rb +22 -0
  31. data/app/models/raif/embedding_models/bedrock.rb +34 -0
  32. data/app/models/raif/embedding_models/open_ai.rb +40 -0
  33. data/app/models/raif/llm.rb +108 -9
  34. data/app/models/raif/llms/anthropic.rb +72 -57
  35. data/app/models/raif/llms/bedrock.rb +165 -0
  36. data/app/models/raif/llms/open_ai_base.rb +66 -0
  37. data/app/models/raif/llms/open_ai_completions.rb +100 -0
  38. data/app/models/raif/llms/open_ai_responses.rb +144 -0
  39. data/app/models/raif/llms/open_router.rb +88 -0
  40. data/app/models/raif/model_completion.rb +23 -2
  41. data/app/models/raif/model_file_input.rb +113 -0
  42. data/app/models/raif/model_image_input.rb +4 -0
  43. data/app/models/raif/model_tool.rb +82 -52
  44. data/app/models/raif/model_tool_invocation.rb +8 -6
  45. data/app/models/raif/model_tools/agent_final_answer.rb +18 -27
  46. data/app/models/raif/model_tools/fetch_url.rb +27 -36
  47. data/app/models/raif/model_tools/provider_managed/base.rb +9 -0
  48. data/app/models/raif/model_tools/provider_managed/code_execution.rb +5 -0
  49. data/app/models/raif/model_tools/provider_managed/image_generation.rb +5 -0
  50. data/app/models/raif/model_tools/provider_managed/web_search.rb +5 -0
  51. data/app/models/raif/model_tools/wikipedia_search.rb +46 -55
  52. data/app/models/raif/streaming_responses/anthropic.rb +63 -0
  53. data/app/models/raif/streaming_responses/bedrock.rb +89 -0
  54. data/app/models/raif/streaming_responses/open_ai_completions.rb +76 -0
  55. data/app/models/raif/streaming_responses/open_ai_responses.rb +54 -0
  56. data/app/models/raif/task.rb +71 -16
  57. data/app/views/layouts/raif/admin.html.erb +10 -0
  58. data/app/views/raif/admin/agents/show.html.erb +3 -1
  59. data/app/views/raif/admin/conversations/_conversation.html.erb +1 -1
  60. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +48 -0
  61. data/app/views/raif/admin/conversations/show.html.erb +4 -2
  62. data/app/views/raif/admin/model_completions/_model_completion.html.erb +8 -0
  63. data/app/views/raif/admin/model_completions/index.html.erb +2 -0
  64. data/app/views/raif/admin/model_completions/show.html.erb +58 -3
  65. data/app/views/raif/admin/stats/index.html.erb +128 -0
  66. data/app/views/raif/admin/stats/tasks/index.html.erb +45 -0
  67. data/app/views/raif/admin/tasks/_task.html.erb +5 -4
  68. data/app/views/raif/admin/tasks/index.html.erb +20 -2
  69. data/app/views/raif/admin/tasks/show.html.erb +3 -1
  70. data/app/views/raif/conversation_entries/_citations.html.erb +9 -0
  71. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +22 -14
  72. data/app/views/raif/conversation_entries/_form.html.erb +1 -1
  73. data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -4
  74. data/app/views/raif/conversation_entries/_message.html.erb +14 -3
  75. data/config/locales/admin.en.yml +16 -0
  76. data/config/locales/en.yml +47 -3
  77. data/config/routes.rb +6 -0
  78. data/db/migrate/20250224234252_create_raif_tables.rb +1 -1
  79. data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +7 -0
  80. data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +14 -0
  81. data/db/migrate/20250424232946_add_created_at_indexes.rb +11 -0
  82. data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +14 -0
  83. data/db/migrate/20250507155314_add_retry_count_to_raif_model_completions.rb +7 -0
  84. data/db/migrate/20250527213016_add_response_id_and_response_array_to_model_completions.rb +14 -0
  85. data/db/migrate/20250603140622_add_citations_to_raif_model_completions.rb +13 -0
  86. data/db/migrate/20250603202013_add_stream_response_to_raif_model_completions.rb +7 -0
  87. data/lib/generators/raif/agent/agent_generator.rb +22 -12
  88. data/lib/generators/raif/agent/templates/agent.rb.tt +3 -3
  89. data/lib/generators/raif/agent/templates/application_agent.rb.tt +7 -0
  90. data/lib/generators/raif/conversation/conversation_generator.rb +10 -0
  91. data/lib/generators/raif/conversation/templates/application_conversation.rb.tt +7 -0
  92. data/lib/generators/raif/conversation/templates/conversation.rb.tt +16 -14
  93. data/lib/generators/raif/install/templates/initializer.rb +62 -6
  94. data/lib/generators/raif/model_tool/model_tool_generator.rb +0 -5
  95. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +69 -56
  96. data/lib/generators/raif/task/templates/task.rb.tt +34 -23
  97. data/lib/raif/configuration.rb +63 -4
  98. data/lib/raif/embedding_model_registry.rb +83 -0
  99. data/lib/raif/engine.rb +56 -7
  100. data/lib/raif/errors/{open_ai/api_error.rb → invalid_model_file_input_error.rb} +1 -3
  101. data/lib/raif/errors/{anthropic/api_error.rb → invalid_model_image_input_error.rb} +1 -3
  102. data/lib/raif/errors/streaming_error.rb +18 -0
  103. data/lib/raif/errors/unsupported_feature_error.rb +8 -0
  104. data/lib/raif/errors.rb +4 -2
  105. data/lib/raif/json_schema_builder.rb +104 -0
  106. data/lib/raif/llm_registry.rb +315 -0
  107. data/lib/raif/migration_checker.rb +74 -0
  108. data/lib/raif/utils/html_fragment_processor.rb +169 -0
  109. data/lib/raif/utils.rb +1 -0
  110. data/lib/raif/version.rb +1 -1
  111. data/lib/raif.rb +7 -32
  112. data/lib/tasks/raif_tasks.rake +9 -4
  113. metadata +62 -12
  114. data/app/models/raif/llms/bedrock_claude.rb +0 -134
  115. data/app/models/raif/llms/open_ai.rb +0 -259
  116. data/lib/raif/default_llms.rb +0 -37
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ class JsonSchemaBuilder
5
+ attr_reader :properties, :required_properties, :items_schema
6
+
7
+ def initialize
8
+ @properties = {}
9
+ @required_properties = []
10
+ @items_schema = nil
11
+ end
12
+
13
+ def string(name, options = {})
14
+ add_property(name, "string", options)
15
+ end
16
+
17
+ def integer(name, options = {})
18
+ add_property(name, "integer", options)
19
+ end
20
+
21
+ def number(name, options = {})
22
+ add_property(name, "number", options)
23
+ end
24
+
25
+ def boolean(name, options = {})
26
+ add_property(name, "boolean", options)
27
+ end
28
+
29
+ def object(name = nil, options = {}, &block)
30
+ schema = {}
31
+
32
+ if block_given?
33
+ nested_builder = self.class.new
34
+ nested_builder.instance_eval(&block)
35
+
36
+ schema[:properties] = nested_builder.properties
37
+ schema[:additionalProperties] = false
38
+
39
+ # We currently use strict mode, which means that all properties are required
40
+ schema[:required] = nested_builder.required_properties
41
+ end
42
+
43
+ # If name is nil, we're inside an array and should return the schema directly
44
+ if name.nil?
45
+ @items_schema = { type: "object" }.merge(schema)
46
+ else
47
+ add_property(name, "object", options.merge(schema))
48
+ end
49
+ end
50
+
51
+ def array(name, options = {}, &block)
52
+ items_schema = options.delete(:items) || {}
53
+
54
+ if block_given?
55
+ nested_builder = self.class.new
56
+ nested_builder.instance_eval(&block)
57
+
58
+ # If items were directly set using the items method
59
+ if nested_builder.items_schema.present?
60
+ items_schema = nested_builder.items_schema
61
+ # If there are properties defined, it's an object schema
62
+ elsif nested_builder.properties.any?
63
+ items_schema = {
64
+ type: "object",
65
+ properties: nested_builder.properties,
66
+ additionalProperties: false
67
+ }
68
+
69
+ # We currently use strict mode, which means that all properties are required
70
+ items_schema[:required] = nested_builder.required_properties
71
+ end
72
+ end
73
+
74
+ options[:items] = items_schema unless items_schema.empty?
75
+ add_property(name, "array", options)
76
+ end
77
+
78
+ # Allow setting array items directly
79
+ def items(options = {})
80
+ @items_schema = options
81
+ end
82
+
83
+ def to_schema
84
+ {
85
+ type: "object",
86
+ additionalProperties: false,
87
+ properties: @properties,
88
+ required: @required_properties
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ def add_property(name, type, options = {})
95
+ property = { type: type }
96
+
97
+ # We currently use strict mode, which means that all properties are required
98
+ @required_properties << name.to_s
99
+
100
+ property.merge!(options)
101
+ @properties[name] = property
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ def self.llm_registry
5
+ @llm_registry ||= {}
6
+ end
7
+
8
+ def self.register_llm(llm_class, llm_config)
9
+ llm = llm_class.new(**llm_config)
10
+
11
+ unless llm.valid?
12
+ raise ArgumentError, "The LLM you tried to register is invalid: #{llm.errors.full_messages.join(", ")}"
13
+ end
14
+
15
+ @llm_registry ||= {}
16
+ @llm_registry[llm.key] = llm_config.merge(llm_class: llm_class)
17
+ end
18
+
19
+ def self.llm(model_key)
20
+ llm_config = llm_registry[model_key]
21
+
22
+ if llm_config.nil?
23
+ raise ArgumentError, "No LLM found for model key: #{model_key}. Available models: #{available_llm_keys.join(", ")}"
24
+ end
25
+
26
+ llm_class = llm_config[:llm_class]
27
+ llm_class.new(**llm_config.except(:llm_class))
28
+ end
29
+
30
+ def self.available_llms
31
+ llm_registry.values
32
+ end
33
+
34
+ def self.available_llm_keys
35
+ llm_registry.keys
36
+ end
37
+
38
+ def self.llm_config(model_key)
39
+ llm_registry[model_key]
40
+ end
41
+
42
+ def self.default_llms
43
+ open_ai_models = [
44
+ {
45
+ key: :open_ai_gpt_4o_mini,
46
+ api_name: "gpt-4o-mini",
47
+ input_token_cost: 0.15 / 1_000_000,
48
+ output_token_cost: 0.6 / 1_000_000,
49
+ },
50
+ {
51
+ key: :open_ai_gpt_4o,
52
+ api_name: "gpt-4o",
53
+ input_token_cost: 2.5 / 1_000_000,
54
+ output_token_cost: 10.0 / 1_000_000,
55
+ },
56
+ {
57
+ key: :open_ai_gpt_3_5_turbo,
58
+ api_name: "gpt-3.5-turbo",
59
+ input_token_cost: 0.5 / 1_000_000,
60
+ output_token_cost: 1.5 / 1_000_000,
61
+ model_provider_settings: { supports_structured_outputs: false }
62
+ },
63
+ {
64
+ key: :open_ai_gpt_4_1,
65
+ api_name: "gpt-4.1",
66
+ input_token_cost: 2.0 / 1_000_000,
67
+ output_token_cost: 8.0 / 1_000_000,
68
+ },
69
+ {
70
+ key: :open_ai_gpt_4_1_mini,
71
+ api_name: "gpt-4.1-mini",
72
+ input_token_cost: 0.4 / 1_000_000,
73
+ output_token_cost: 1.6 / 1_000_000,
74
+ },
75
+ {
76
+ key: :open_ai_gpt_4_1_nano,
77
+ api_name: "gpt-4.1-nano",
78
+ input_token_cost: 0.1 / 1_000_000,
79
+ output_token_cost: 0.4 / 1_000_000,
80
+ },
81
+ {
82
+ key: :open_ai_o1,
83
+ api_name: "o1",
84
+ input_token_cost: 15.0 / 1_000_000,
85
+ output_token_cost: 60.0 / 1_000_000,
86
+ model_provider_settings: { supports_temperature: false },
87
+ },
88
+ {
89
+ key: :open_ai_o1_mini,
90
+ api_name: "o1-mini",
91
+ input_token_cost: 1.5 / 1_000_000,
92
+ output_token_cost: 6.0 / 1_000_000,
93
+ model_provider_settings: { supports_temperature: false },
94
+ },
95
+ {
96
+ key: :open_ai_o3,
97
+ api_name: "o3",
98
+ input_token_cost: 2.0 / 1_000_000,
99
+ output_token_cost: 8.0 / 1_000_000,
100
+ model_provider_settings: { supports_temperature: false },
101
+ },
102
+ {
103
+ key: :open_ai_o3_mini,
104
+ api_name: "o3-mini",
105
+ input_token_cost: 1.1 / 1_000_000,
106
+ output_token_cost: 4.4 / 1_000_000,
107
+ model_provider_settings: { supports_temperature: false },
108
+ },
109
+ {
110
+ key: :open_ai_o4_mini,
111
+ api_name: "o4-mini",
112
+ input_token_cost: 1.1 / 1_000_000,
113
+ output_token_cost: 4.4 / 1_000_000,
114
+ model_provider_settings: { supports_temperature: false },
115
+ },
116
+ ]
117
+
118
+ open_ai_responses_models = open_ai_models.dup.map.with_index do |model, _index|
119
+ model.merge(
120
+ key: model[:key].to_s.gsub("open_ai_", "open_ai_responses_").to_sym,
121
+ supported_provider_managed_tools: [
122
+ Raif::ModelTools::ProviderManaged::WebSearch,
123
+ Raif::ModelTools::ProviderManaged::CodeExecution,
124
+ Raif::ModelTools::ProviderManaged::ImageGeneration
125
+ ]
126
+ )
127
+ end
128
+
129
+ # o1-mini is not supported by the OpenAI Responses API.
130
+ open_ai_responses_models.delete_if{|model| model[:key] == :open_ai_o1_mini }
131
+
132
+ # o1-pro and o3-pro are not supported by the OpenAI Completions API, but it is supported by the OpenAI Responses API.
133
+ open_ai_responses_models << {
134
+ key: :open_ai_responses_o1_pro,
135
+ api_name: "o1-pro",
136
+ input_token_cost: 150.0 / 1_000_000,
137
+ output_token_cost: 600.0 / 1_000_000,
138
+ model_provider_settings: { supports_temperature: false },
139
+ }
140
+
141
+ open_ai_responses_models << {
142
+ key: :open_ai_responses_o3_pro,
143
+ api_name: "o3-pro",
144
+ input_token_cost: 20.0 / 1_000_000,
145
+ output_token_cost: 80.0 / 1_000_000,
146
+ model_provider_settings: { supports_temperature: false },
147
+ }
148
+
149
+ {
150
+ Raif::Llms::OpenAiCompletions => open_ai_models,
151
+ Raif::Llms::OpenAiResponses => open_ai_responses_models,
152
+ Raif::Llms::Anthropic => [
153
+ {
154
+ key: :anthropic_claude_4_sonnet,
155
+ api_name: "claude-sonnet-4-20250514",
156
+ input_token_cost: 3.0 / 1_000_000,
157
+ output_token_cost: 15.0 / 1_000_000,
158
+ max_completion_tokens: 8192,
159
+ supported_provider_managed_tools: [
160
+ Raif::ModelTools::ProviderManaged::WebSearch,
161
+ Raif::ModelTools::ProviderManaged::CodeExecution
162
+ ]
163
+ },
164
+ {
165
+ key: :anthropic_claude_4_opus,
166
+ api_name: "claude-opus-4-20250514",
167
+ input_token_cost: 15.0 / 1_000_000,
168
+ output_token_cost: 75.0 / 1_000_000,
169
+ max_completion_tokens: 8192,
170
+ supported_provider_managed_tools: [
171
+ Raif::ModelTools::ProviderManaged::WebSearch,
172
+ Raif::ModelTools::ProviderManaged::CodeExecution
173
+ ]
174
+ },
175
+ {
176
+ key: :anthropic_claude_3_7_sonnet,
177
+ api_name: "claude-3-7-sonnet-latest",
178
+ input_token_cost: 3.0 / 1_000_000,
179
+ output_token_cost: 15.0 / 1_000_000,
180
+ max_completion_tokens: 8192,
181
+ supported_provider_managed_tools: [
182
+ Raif::ModelTools::ProviderManaged::WebSearch,
183
+ Raif::ModelTools::ProviderManaged::CodeExecution
184
+ ]
185
+ },
186
+ {
187
+ key: :anthropic_claude_3_5_sonnet,
188
+ api_name: "claude-3-5-sonnet-latest",
189
+ input_token_cost: 3.0 / 1_000_000,
190
+ output_token_cost: 15.0 / 1_000_000,
191
+ max_completion_tokens: 8192,
192
+ supported_provider_managed_tools: [
193
+ Raif::ModelTools::ProviderManaged::WebSearch,
194
+ Raif::ModelTools::ProviderManaged::CodeExecution
195
+ ]
196
+ },
197
+ {
198
+ key: :anthropic_claude_3_5_haiku,
199
+ api_name: "claude-3-5-haiku-latest",
200
+ input_token_cost: 0.8 / 1_000_000,
201
+ output_token_cost: 4.0 / 1_000_000,
202
+ max_completion_tokens: 8192,
203
+ supported_provider_managed_tools: [
204
+ Raif::ModelTools::ProviderManaged::WebSearch,
205
+ Raif::ModelTools::ProviderManaged::CodeExecution
206
+ ]
207
+ },
208
+ {
209
+ key: :anthropic_claude_3_opus,
210
+ api_name: "claude-3-opus-latest",
211
+ input_token_cost: 15.0 / 1_000_000,
212
+ output_token_cost: 75.0 / 1_000_000,
213
+ max_completion_tokens: 4096
214
+ },
215
+ ],
216
+ Raif::Llms::Bedrock => [
217
+ {
218
+ key: :bedrock_claude_4_sonnet,
219
+ api_name: "anthropic.claude-sonnet-4-20250514-v1:0",
220
+ input_token_cost: 0.003 / 1000,
221
+ output_token_cost: 0.015 / 1000,
222
+ max_completion_tokens: 8192
223
+ },
224
+ {
225
+ key: :bedrock_claude_4_opus,
226
+ api_name: "anthropic.claude-opus-4-20250514-v1:0",
227
+ input_token_cost: 0.015 / 1000,
228
+ output_token_cost: 0.075 / 1000,
229
+ max_completion_tokens: 8192
230
+ },
231
+ {
232
+ key: :bedrock_claude_3_5_sonnet,
233
+ api_name: "anthropic.claude-3-5-sonnet-20241022-v2:0",
234
+ input_token_cost: 0.003 / 1000,
235
+ output_token_cost: 0.015 / 1000,
236
+ max_completion_tokens: 8192
237
+ },
238
+ {
239
+ key: :bedrock_claude_3_7_sonnet,
240
+ api_name: "anthropic.claude-3-7-sonnet-20250219-v1:0",
241
+ input_token_cost: 0.003 / 1000,
242
+ output_token_cost: 0.015 / 1000,
243
+ max_completion_tokens: 8192
244
+ },
245
+ {
246
+ key: :bedrock_claude_3_5_haiku,
247
+ api_name: "anthropic.claude-3-5-haiku-20241022-v1:0",
248
+ input_token_cost: 0.0008 / 1000,
249
+ output_token_cost: 0.004 / 1000,
250
+ max_completion_tokens: 8192
251
+ },
252
+ {
253
+ key: :bedrock_claude_3_opus,
254
+ api_name: "anthropic.claude-3-opus-20240229-v1:0",
255
+ input_token_cost: 0.015 / 1000,
256
+ output_token_cost: 0.075 / 1000,
257
+ max_completion_tokens: 4096
258
+ },
259
+ {
260
+ key: :bedrock_amazon_nova_micro,
261
+ api_name: "amazon.nova-micro-v1:0",
262
+ input_token_cost: 0.0000115 / 1000,
263
+ output_token_cost: 0.000184 / 1000,
264
+ max_completion_tokens: 4096
265
+ },
266
+ {
267
+ key: :bedrock_amazon_nova_lite,
268
+ api_name: "amazon.nova-lite-v1:0",
269
+ input_token_cost: 0.0000195 / 1000,
270
+ output_token_cost: 0.000312 / 1000,
271
+ max_completion_tokens: 4096
272
+ },
273
+ {
274
+ key: :bedrock_amazon_nova_pro,
275
+ api_name: "amazon.nova-pro-v1:0",
276
+ input_token_cost: 0.0002625 / 1000,
277
+ output_token_cost: 0.0042 / 1000,
278
+ max_completion_tokens: 4096
279
+ }
280
+ ],
281
+ Raif::Llms::OpenRouter => [
282
+ {
283
+ key: :open_router_claude_3_7_sonnet,
284
+ api_name: "anthropic/claude-3.7-sonnet",
285
+ input_token_cost: 3.0 / 1_000_000,
286
+ output_token_cost: 15.0 / 1_000_000,
287
+ },
288
+ {
289
+ key: :open_router_llama_3_3_70b_instruct,
290
+ api_name: "meta-llama/llama-3.3-70b-instruct",
291
+ input_token_cost: 0.10 / 1_000_000,
292
+ output_token_cost: 0.25 / 1_000_000,
293
+ },
294
+ {
295
+ key: :open_router_llama_3_1_8b_instruct,
296
+ api_name: "meta-llama/llama-3.1-8b-instruct",
297
+ input_token_cost: 0.02 / 1_000_000,
298
+ output_token_cost: 0.03 / 1_000_000,
299
+ },
300
+ {
301
+ key: :open_router_gemini_2_0_flash,
302
+ api_name: "google/gemini-2.0-flash-001",
303
+ input_token_cost: 0.1 / 1_000_000,
304
+ output_token_cost: 0.4 / 1_000_000,
305
+ },
306
+ {
307
+ key: :open_router_deepseek_chat_v3,
308
+ api_name: "deepseek/deepseek-chat-v3-0324",
309
+ input_token_cost: 0.27 / 1_000_000,
310
+ output_token_cost: 1.1 / 1_000_000,
311
+ },
312
+ ]
313
+ }
314
+ end
315
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ module MigrationChecker
5
+ class << self
6
+ def uninstalled_migrations
7
+ engine_migration_names = engine_migration_names_from_context
8
+ ran_migration_names = ran_migration_names_from_host
9
+
10
+ engine_migration_names - ran_migration_names
11
+ end
12
+
13
+ def check_and_warn!
14
+ return unless defined?(Rails) && Rails.application
15
+
16
+ uninstalled = uninstalled_migrations
17
+ return if uninstalled.empty?
18
+
19
+ warning_message = build_warning_message(uninstalled)
20
+
21
+ # Output to both logger and STDOUT to ensure visibility
22
+ Rails.logger&.warn(warning_message)
23
+ warn warning_message
24
+ end
25
+
26
+ private
27
+
28
+ def engine_migration_names_from_context
29
+ engine_paths = Raif::Engine.paths["db/migrate"].existent
30
+ return [] if engine_paths.empty?
31
+
32
+ ActiveRecord::MigrationContext.new(engine_paths).migrations.map(&:name)
33
+ rescue => e
34
+ Rails.logger&.debug("Raif: Could not load engine migrations: #{e.message}")
35
+ []
36
+ end
37
+
38
+ def ran_migration_names_from_host
39
+ return [] unless defined?(Rails) && Rails.application
40
+
41
+ app_paths = Rails.application.paths["db/migrate"].expanded
42
+ return [] if app_paths.empty?
43
+
44
+ ctx = ActiveRecord::MigrationContext.new(app_paths)
45
+ ran_versions = ctx.get_all_versions
46
+ ctx.migrations.select{|m| ran_versions.include?(m.version) }.map(&:name)
47
+ rescue ActiveRecord::NoDatabaseError
48
+ # Database doesn't exist yet, so no migrations have been run
49
+ []
50
+ rescue => e
51
+ Rails.logger&.debug("Raif: Could not load migration status: #{e.message}")
52
+ []
53
+ end
54
+
55
+ def build_warning_message(uninstalled_migration_names)
56
+ <<~WARNING
57
+ \e[33m
58
+ ⚠️ RAIF MIGRATION WARNING ⚠️
59
+
60
+ The following Raif migrations have not been run in your application:
61
+
62
+ #{uninstalled_migration_names.map { |name| " • #{name}" }.join("\n")}
63
+
64
+ To install and run these migrations:
65
+
66
+ rails raif:install:migrations
67
+ rails db:migrate
68
+
69
+ \e[0m
70
+ WARNING
71
+ end
72
+ end
73
+ end
74
+ end
@@ -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.0.0"
4
+ VERSION = "1.2.0"
5
5
  end