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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1af0a7a003990a40716f3d07ec87838e5fb485147bb87a9b35ae1fe23f642501
4
- data.tar.gz: b82bba67ed23a6e47e1bd02f5c64f9448c7374017c097a012ccdae2e3795be34
3
+ metadata.gz: 93d75d28d64da2a5559de740890291cb3a125d981bb58aa56ff4c45be0169954
4
+ data.tar.gz: 9f3ca2e5142f43be17c9fe27e3572cd08a4241e72d6e43893b74e6ba954169ae
5
5
  SHA512:
6
- metadata.gz: d9dd7273eeccb284d7ee720fe586dc1e86b6e3355da378a719310cbe3209c508a57a3ee1cfcaa60762cd2860f76960b4f89aa39d7f5eafa90eb97b78adab6123
7
- data.tar.gz: 26013bb1beb60367d878b451163594c50e36ef046b8ae0864a4728c4d0ae862e79ec22ab5ccb6c0acd88991b9f227b3af4b31daedc76e90755e9d87bce45f25c
6
+ metadata.gz: 90726f18f49a312f0e8d2ceb3913c1bb45c50ad237f0fceeefa825e28374214a8c61ae84c4a83e6d3188a27adea3dc0162001792d867babadca5e30f5f736398
7
+ data.tar.gz: e053003fd9c510509cbc749d5f20d9d2596396224499fe7cc40467920a216f4da6efb3c281ed451ff7e29bced98ff17476c4779b7760a3e361c4a09285667deb
data/README.md CHANGED
@@ -13,16 +13,21 @@ Raif is built by [Cultivate Labs](https://www.cultivatelabs.com) and is used to
13
13
  ## Table of Contents
14
14
  - [Setup](#setup)
15
15
  - [OpenAI](#openai)
16
+ - [OpenAI Completions API](#openai-completions-api)
17
+ - [OpenAI Responses API](#openai-responses-api)
16
18
  - [Anthropic Claude](#anthropic-claude)
17
19
  - [AWS Bedrock (Claude)](#aws-bedrock-claude)
18
20
  - [OpenRouter](#openrouter)
19
21
  - [Chatting with the LLM](#chatting-with-the-llm)
22
+ - [Streaming Responses](#streaming-responses)
20
23
  - [Key Raif Concepts](#key-raif-concepts)
21
24
  - [Tasks](#tasks)
22
25
  - [Conversations](#conversations)
26
+ - [Real-time Streaming Responses](#real-time-streaming-responses)
23
27
  - [Conversation Types](#conversation-types)
24
28
  - [Agents](#agents)
25
29
  - [Model Tools](#model-tools)
30
+ - [Provider-Managed Tools](#provider-managed-tools)
26
31
  - [Images/Files/PDF's](#imagesfilespdfs)
27
32
  - [Images/Files/PDF's in Tasks](#imagesfilespdfs-in-tasks)
28
33
  - [Embedding Models](#embedding-models)
@@ -35,6 +40,7 @@ Raif is built by [Cultivate Labs](https://www.cultivatelabs.com) and is used to
35
40
  - [Adding LLM Models](#adding-llm-models)
36
41
  - [Testing](#testing)
37
42
  - [Demo App](#demo-app)
43
+ - [Contributing](#contributing)
38
44
  - [License](#license)
39
45
 
40
46
  # Setup
@@ -60,6 +66,8 @@ This will:
60
66
  - Copy Raif's database migrations to your application
61
67
  - Mount Raif's engine at `/raif` in your application's `config/routes.rb` file
62
68
 
69
+ You must configure at least one API key for your LLM provider ([OpenAI](#openai), [Anthropic Claude](#anthropic-claude), [AWS Bedrock](#aws-bedrock-claude), [OpenRouter](#openrouter)). By default, the initializer will load them from environment variables (e.g. `ENV["OPENAI_API_KEY"]`, `ENV["ANTHROPIC_API_KEY"]`, `ENV["OPENROUTER_API_KEY"]`). Alternatively, you can set them directly in `config/initializers/raif.rb`.
70
+
63
71
  Run the migrations. Raif is compatible with both PostgreSQL and MySQL databases.
64
72
  ```bash
65
73
  rails db:migrate
@@ -82,6 +90,10 @@ end
82
90
  Configure your LLM providers. You'll need at least one of:
83
91
 
84
92
  ## OpenAI
93
+
94
+ Raif supports both OpenAI's [Completions API](https://platform.openai.com/docs/api-reference/chat) and the newer [Responses API](https://platform.openai.com/docs/api-reference/responses), which provides access to provider-managed tools like web search, code execution, and image generation.
95
+
96
+ ### OpenAI Completions API
85
97
  ```ruby
86
98
  Raif.configure do |config|
87
99
  config.open_ai_models_enabled = true
@@ -90,10 +102,44 @@ Raif.configure do |config|
90
102
  end
91
103
  ```
92
104
 
93
- Currently supported OpenAI models:
105
+ Currently supported OpenAI Completions API models:
94
106
  - `open_ai_gpt_4o_mini`
95
107
  - `open_ai_gpt_4o`
96
108
  - `open_ai_gpt_3_5_turbo`
109
+ - `open_ai_gpt_4_1`
110
+ - `open_ai_gpt_4_1_mini`
111
+ - `open_ai_gpt_4_1_nano`
112
+ - `open_ai_o1`
113
+ - `open_ai_o1_mini`
114
+ - `open_ai_o3`
115
+ - `open_ai_o3_mini`
116
+ - `open_ai_o4_mini`
117
+
118
+ ### OpenAI Responses API
119
+ ```ruby
120
+ Raif.configure do |config|
121
+ config.open_ai_models_enabled = true
122
+ config.open_ai_api_key = ENV["OPENAI_API_KEY"]
123
+ config.default_llm_model_key = "open_ai_responses_gpt_4o"
124
+ end
125
+ ```
126
+
127
+ Currently supported OpenAI Responses API models:
128
+ - `open_ai_responses_gpt_4o_mini`
129
+ - `open_ai_responses_gpt_4o`
130
+ - `open_ai_responses_gpt_3_5_turbo`
131
+ - `open_ai_responses_gpt_4_1`
132
+ - `open_ai_responses_gpt_4_1_mini`
133
+ - `open_ai_responses_gpt_4_1_nano`
134
+ - `open_ai_responses_o1`
135
+ - `open_ai_responses_o1_mini`
136
+ - `open_ai_responses_o1_pro`
137
+ - `open_ai_responses_o3`
138
+ - `open_ai_responses_o3_mini`
139
+ - `open_ai_responses_o3_pro`
140
+ - `open_ai_responses_o4_mini`
141
+
142
+ The Responses API provides access to [provider-managed tools](#provider-managed-tools), including web search, code execution, and image generation.
97
143
 
98
144
  ## Anthropic Claude
99
145
  ```ruby
@@ -110,10 +156,12 @@ Currently supported Anthropic models:
110
156
  - `anthropic_claude_3_5_haiku`
111
157
  - `anthropic_claude_3_opus`
112
158
 
159
+ The Anthropic adapter provides access to [provider-managed tools](#provider-managed-tools) for web search and code execution.
160
+
113
161
  ## AWS Bedrock (Claude)
114
162
  ```ruby
115
163
  Raif.configure do |config|
116
- config.anthropic_bedrock_models_enabled = true
164
+ config.bedrock_models_enabled = true
117
165
  config.aws_bedrock_region = "us-east-1"
118
166
  config.default_llm_model_key = "bedrock_claude_3_5_sonnet"
119
167
  end
@@ -124,6 +172,9 @@ Currently supported Bedrock models:
124
172
  - `bedrock_claude_3_7_sonnet`
125
173
  - `bedrock_claude_3_5_haiku`
126
174
  - `bedrock_claude_3_opus`
175
+ - `bedrock_amazon_nova_micro`
176
+ - `bedrock_amazon_nova_lite`
177
+ - `bedrock_amazon_nova_pro`
127
178
 
128
179
  Note: Raif utilizes the [AWS Bedrock gem](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/BedrockRuntime/Client.html) and AWS credentials should be configured via the AWS SDK (environment variables, IAM role, etc.)
129
180
 
@@ -184,6 +235,38 @@ puts model_completion.parsed_response # will strip backticks, parse the JSON, an
184
235
  # => {"joke" => "Why don't skeletons fight each other? They don't have the guts."}
185
236
  ```
186
237
 
238
+ ## Streaming Responses
239
+
240
+ You can enable streaming for any chat call by passing a block to the `chat` method. When streaming is enabled, the block will be called with partial responses as they're received from the LLM:
241
+
242
+ ```ruby
243
+ llm = Raif.llm(:open_ai_gpt_4o)
244
+ model_completion = llm.chat(message: "Tell me a story") do |model_completion, delta, sse_event|
245
+ # This block is called multiple times as the response streams in.
246
+ # You could broadcast these updates via Turbo Streams, WebSockets, etc.
247
+ Turbo::StreamsChannel.broadcast_replace_to(
248
+ :my_channel,
249
+ target: "chat-response",
250
+ partial: "my_partial_displaying_chat_response",
251
+ locals: { model_completion: model_completion, delta: delta, sse_event: sse_event }
252
+ )
253
+ end
254
+
255
+ # The final complete response is available in the model_completion
256
+ puts model_completion.raw_response
257
+ ```
258
+
259
+ You can configure the streaming update frequency by adjusting the chunk size threshold in your Raif configuration:
260
+
261
+ ```ruby
262
+ Raif.configure do |config|
263
+ # Control how often the model completion is updated & the block is called when streaming.
264
+ # Lower values = more frequent updates but more database writes.
265
+ # Higher values = less frequent updates but fewer database writes.
266
+ config.streaming_update_chunk_size_threshold = 50 # default is 25
267
+ end
268
+ ```
269
+
187
270
  # Key Raif Concepts
188
271
 
189
272
  ## Tasks
@@ -334,6 +417,10 @@ If your app already includes Bootstrap styles, this will render a conversation i
334
417
 
335
418
  If your app does not include Bootstrap, you can [override the views](#views) to update styles.
336
419
 
420
+ ### Real-time Streaming Responses
421
+
422
+ Raif conversations have built-in support for streaming responses, where the LLM's response is displayed progressively as it's being generated. Each time a conversation entry is updated during the streaming response, Raif will call `broadcast_replace_to(conversation)` (where `conversation` is the `Raif::Conversation` associated with the conversation entry). When using the `raif_conversation` view helper, it will automatically set up the subscription for you.
423
+
337
424
  ### Conversation Types
338
425
 
339
426
  If your application has a specific type of conversation that you use frequently, you can create a custom conversation type by running the generator. For example, say you are implementing a customer support chatbot in your application and want to have a custom conversation type for doing this with the LLM:
@@ -520,7 +607,54 @@ class Raif::ModelTools::GoogleSearch < Raif::ModelTool
520
607
  end
521
608
  ```
522
609
 
523
- ## Images/Files/PDF's
610
+ ### Provider-Managed Tools
611
+
612
+ In addition to the ability to create your own model tools, Raif supports provider-managed tools. These are tools that are built into certain LLM providers and run on the provider's infrastructure:
613
+
614
+ - **`Raif::ModelTools::ProviderManaged::WebSearch`**: Performs real-time web searches and returns relevant results
615
+ - **`Raif::ModelTools::ProviderManaged::CodeExecution`**: Executes code in a secure sandboxed environment (e.g. Python)
616
+ - **`Raif::ModelTools::ProviderManaged::ImageGeneration`**: Generates images based on text descriptions
617
+
618
+ Current provider-managed tool support:
619
+ | Provider | WebSearch | CodeExecution | ImageGeneration |
620
+ |----------|-----------|---------------|-----------------|
621
+ | OpenAI Responses API | ✅ | ✅ | ✅ |
622
+ | OpenAI Completions API | ❌ | ❌ | ❌ |
623
+ | Anthropic Claude | ✅ | ✅ | ❌ |
624
+ | AWS Bedrock (Claude) | ❌ | ❌ | ❌ |
625
+ | OpenRouter | ❌ | ❌ | ❌ |
626
+
627
+ To use provider-managed tools, include them in the `available_model_tools` array:
628
+
629
+ ```ruby
630
+ # In a conversation
631
+ conversation = Raif::Conversation.create!(
632
+ creator: current_user,
633
+ available_model_tools: [
634
+ "Raif::ModelTools::ProviderManaged::WebSearch",
635
+ "Raif::ModelTools::ProviderManaged::CodeExecution"
636
+ ]
637
+ )
638
+
639
+ # In an agent
640
+ agent = Raif::Agents::ReActAgent.new(
641
+ task: "Search for recent news about AI and create a summary chart",
642
+ available_model_tools: [
643
+ "Raif::ModelTools::ProviderManaged::WebSearch",
644
+ "Raif::ModelTools::ProviderManaged::CodeExecution"
645
+ ],
646
+ creator: current_user
647
+ )
648
+
649
+ # Directly in a chat
650
+ llm = Raif.llm(:open_ai_responses_gpt_4_1)
651
+ model_completion = llm.chat(
652
+ messages: [{ role: "user", content: "What are the latest developments in Ruby on Rails?" }],
653
+ available_model_tools: [Raif::ModelTools::ProviderManaged::WebSearch]
654
+ )
655
+ ```
656
+
657
+ ## Sending Images/Files/PDF's to the LLM
524
658
 
525
659
  Raif supports images, files, and PDF's in the messages sent to the LLM.
526
660
 
@@ -596,7 +730,7 @@ Raif supports generation of vector embeddings. You can enable and configure embe
596
730
  ```ruby
597
731
  Raif.configure do |config|
598
732
  config.open_ai_embedding_models_enabled = true
599
- config.aws_bedrock_titan_embedding_models_enabled = true
733
+ config.bedrock_embedding_models_enabled = true
600
734
 
601
735
  config.default_embedding_model_key = "open_ai_text_embedding_3_small"
602
736
  end
@@ -649,6 +783,7 @@ The admin interface contains sections for:
649
783
  - Conversations
650
784
  - Agents
651
785
  - Model Tool Invocations
786
+ - Stats
652
787
 
653
788
 
654
789
  ### Model Completions
@@ -670,6 +805,9 @@ The admin interface contains sections for:
670
805
  ![Model Tool Invocations Index](./screenshots/admin-model-tool-invocations-index.png)
671
806
  ![Model Tool Invocation Detail](./screenshots/admin-model-tool-invocation-show.png)
672
807
 
808
+ ### Stats
809
+ ![Stats](./screenshots/admin-stats.png)
810
+
673
811
  # Customization
674
812
 
675
813
  ## Controllers
@@ -832,6 +970,12 @@ You can then access the app at [http://localhost:3000](http://localhost:3000).
832
970
 
833
971
  ![Demo App Screenshot](./screenshots/demo-app.png)
834
972
 
973
+ # Contributing
974
+
975
+ We welcome contributions to Raif! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
976
+
977
+ **Important**: All PR's should be made against the `dev` branch.
978
+
835
979
  # License
836
980
 
837
981
  The gem is available as open source under the terms of the MIT License.
@@ -28,6 +28,31 @@
28
28
  animation-delay: 0.4s;
29
29
  }
30
30
 
31
+ .raif-streaming-cursor {
32
+ display: inline-block;
33
+ width: 2px;
34
+ height: 1.1em;
35
+ margin-bottom: -2px;
36
+ background-color: currentColor;
37
+ animation: blink 1s infinite;
38
+ transform: none;
39
+ border-radius: 0;
40
+ position: relative;
41
+ }
42
+
43
+ .raif-streaming-cursor:before,
44
+ .raif-streaming-cursor:after {
45
+ display: none;
46
+ }
47
+
48
+ @keyframes blink {
49
+ 0%, 50% {
50
+ opacity: 1;
51
+ }
52
+ 51%, 100% {
53
+ opacity: 0;
54
+ }
55
+ }
31
56
  @keyframes rotate {
32
57
  0% {
33
58
  transform: translate(-50%, -50%) rotateZ(0deg);
@@ -71,4 +96,4 @@
71
96
  }
72
97
  }
73
98
 
74
- /*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInJhaWYuY3NzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO0VBQ0UseUJBQXlCO0VBQ3pCLG1CQUFtQjtFQUNuQixrQkFBa0I7RUFDbEIsV0FBVztFQUNYLFlBQVk7RUFDWixjQUFjO0VBQ2QscUJBQXFCO0FBQ3ZCOztBQUVBOztFQUVFLFdBQVc7RUFDWCxjQUFjO0VBQ2Qsa0JBQWtCO0VBQ2xCLE1BQU07RUFDTixPQUFPO0VBQ1AsY0FBYztFQUNkLGVBQWU7RUFDZixrQkFBa0I7RUFDbEIseUJBQXlCO0VBQ3pCLGtDQUFrQztBQUNwQzs7QUFFQTtFQUNFLGNBQWM7RUFDZCx5QkFBeUI7RUFDekIscUJBQXFCO0FBQ3ZCOztBQUVBO0VBQ0U7SUFDRSw4Q0FBOEM7RUFDaEQ7RUFDQTtJQUNFLGdEQUFnRDtFQUNsRDtBQUNGO0FBQ0E7RUFDRTtJQUNFLDZDQUE2QztFQUMvQztFQUNBO0lBQ0UsZ0RBQWdEO0VBQ2xEO0FBQ0Y7QUFDQTtFQUNFO0lBQ0Usd0NBQXdDO0VBQzFDO0VBQ0E7SUFDRSx3Q0FBd0M7RUFDMUM7RUFDQTtJQUNFLHNDQUFzQztFQUN4QztFQUNBO0lBQ0UseUNBQXlDO0VBQzNDO0VBQ0E7SUFDRSxxQ0FBcUM7RUFDdkM7RUFDQTtJQUNFLDBDQUEwQztFQUM1QztFQUNBO0lBQ0UsdUNBQXVDO0VBQ3pDO0VBQ0E7SUFDRSx5Q0FBeUM7RUFDM0M7QUFDRiIsImZpbGUiOiJyYWlmLmNzcyIsInNvdXJjZXNDb250ZW50IjpbIi5yYWlmLWxvYWRlciB7XG4gIHRyYW5zZm9ybTogcm90YXRlWig0NWRlZyk7XG4gIHBlcnNwZWN0aXZlOiAxMDAwcHg7XG4gIGJvcmRlci1yYWRpdXM6IDUwJTtcbiAgd2lkdGg6IDI1cHg7XG4gIGhlaWdodDogMjVweDtcbiAgY29sb3I6ICMzODc0ZmY7XG4gIGRpc3BsYXk6IGlubGluZS1ibG9jaztcbn1cblxuLnJhaWYtbG9hZGVyOmJlZm9yZSxcbi5yYWlmLWxvYWRlcjphZnRlciB7XG4gIGNvbnRlbnQ6IFwiXCI7XG4gIGRpc3BsYXk6IGJsb2NrO1xuICBwb3NpdGlvbjogYWJzb2x1dGU7XG4gIHRvcDogMDtcbiAgbGVmdDogMDtcbiAgd2lkdGg6IGluaGVyaXQ7XG4gIGhlaWdodDogaW5oZXJpdDtcbiAgYm9yZGVyLXJhZGl1czogNTAlO1xuICB0cmFuc2Zvcm06IHJvdGF0ZVgoNzBkZWcpO1xuICBhbmltYXRpb246IDFzIHNwaW4gbGluZWFyIGluZmluaXRlO1xufVxuXG4ucmFpZi1sb2FkZXI6YWZ0ZXIge1xuICBjb2xvcjogIzI1YjAwMztcbiAgdHJhbnNmb3JtOiByb3RhdGVZKDcwZGVnKTtcbiAgYW5pbWF0aW9uLWRlbGF5OiAwLjRzO1xufVxuXG5Aa2V5ZnJhbWVzIHJvdGF0ZSB7XG4gIDAlIHtcbiAgICB0cmFuc2Zvcm06IHRyYW5zbGF0ZSgtNTAlLCAtNTAlKSByb3RhdGVaKDBkZWcpO1xuICB9XG4gIDEwMCUge1xuICAgIHRyYW5zZm9ybTogdHJhbnNsYXRlKC01MCUsIC01MCUpIHJvdGF0ZVooMzYwZGVnKTtcbiAgfVxufVxuQGtleWZyYW1lcyByb3RhdGVjY3cge1xuICAwJSB7XG4gICAgdHJhbnNmb3JtOiB0cmFuc2xhdGUoLTUwJSwgLTUwJSkgcm90YXRlKDBkZWcpO1xuICB9XG4gIDEwMCUge1xuICAgIHRyYW5zZm9ybTogdHJhbnNsYXRlKC01MCUsIC01MCUpIHJvdGF0ZSgtMzYwZGVnKTtcbiAgfVxufVxuQGtleWZyYW1lcyBzcGluIHtcbiAgMCUsIDEwMCUge1xuICAgIGJveC1zaGFkb3c6IDAuM2VtIDBweCAwIDBweCBjdXJyZW50Y29sb3I7XG4gIH1cbiAgMTIlIHtcbiAgICBib3gtc2hhZG93OiAwLjNlbSAwLjNlbSAwIDAgY3VycmVudGNvbG9yO1xuICB9XG4gIDI1JSB7XG4gICAgYm94LXNoYWRvdzogMCAwLjNlbSAwIDBweCBjdXJyZW50Y29sb3I7XG4gIH1cbiAgMzclIHtcbiAgICBib3gtc2hhZG93OiAtMC4zZW0gMC4zZW0gMCAwIGN1cnJlbnRjb2xvcjtcbiAgfVxuICA1MCUge1xuICAgIGJveC1zaGFkb3c6IC0wLjNlbSAwIDAgMCBjdXJyZW50Y29sb3I7XG4gIH1cbiAgNjIlIHtcbiAgICBib3gtc2hhZG93OiAtMC4zZW0gLTAuM2VtIDAgMCBjdXJyZW50Y29sb3I7XG4gIH1cbiAgNzUlIHtcbiAgICBib3gtc2hhZG93OiAwcHggLTAuM2VtIDAgMCBjdXJyZW50Y29sb3I7XG4gIH1cbiAgODclIHtcbiAgICBib3gtc2hhZG93OiAwLjNlbSAtMC4zZW0gMCAwIGN1cnJlbnRjb2xvcjtcbiAgfVxufVxuIl19 */
99
+ /*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInJhaWYuY3NzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO0VBQ0UseUJBQXlCO0VBQ3pCLG1CQUFtQjtFQUNuQixrQkFBa0I7RUFDbEIsV0FBVztFQUNYLFlBQVk7RUFDWixjQUFjO0VBQ2QscUJBQXFCO0FBQ3ZCOztBQUVBOztFQUVFLFdBQVc7RUFDWCxjQUFjO0VBQ2Qsa0JBQWtCO0VBQ2xCLE1BQU07RUFDTixPQUFPO0VBQ1AsY0FBYztFQUNkLGVBQWU7RUFDZixrQkFBa0I7RUFDbEIseUJBQXlCO0VBQ3pCLGtDQUFrQztBQUNwQzs7QUFFQTtFQUNFLGNBQWM7RUFDZCx5QkFBeUI7RUFDekIscUJBQXFCO0FBQ3ZCOztBQUVBO0VBQ0UscUJBQXFCO0VBQ3JCLFVBQVU7RUFDVixhQUFhO0VBQ2IsbUJBQW1CO0VBQ25CLDhCQUE4QjtFQUM5Qiw0QkFBNEI7RUFDNUIsZUFBZTtFQUNmLGdCQUFnQjtFQUNoQixrQkFBa0I7QUFDcEI7O0FBRUE7O0VBRUUsYUFBYTtBQUNmOztBQUVBO0VBQ0U7SUFDRSxVQUFVO0VBQ1o7RUFDQTtJQUNFLFVBQVU7RUFDWjtBQUNGO0FBQ0E7RUFDRTtJQUNFLDhDQUE4QztFQUNoRDtFQUNBO0lBQ0UsZ0RBQWdEO0VBQ2xEO0FBQ0Y7QUFDQTtFQUNFO0lBQ0UsNkNBQTZDO0VBQy9DO0VBQ0E7SUFDRSxnREFBZ0Q7RUFDbEQ7QUFDRjtBQUNBO0VBQ0U7SUFDRSx3Q0FBd0M7RUFDMUM7RUFDQTtJQUNFLHdDQUF3QztFQUMxQztFQUNBO0lBQ0Usc0NBQXNDO0VBQ3hDO0VBQ0E7SUFDRSx5Q0FBeUM7RUFDM0M7RUFDQTtJQUNFLHFDQUFxQztFQUN2QztFQUNBO0lBQ0UsMENBQTBDO0VBQzVDO0VBQ0E7SUFDRSx1Q0FBdUM7RUFDekM7RUFDQTtJQUNFLHlDQUF5QztFQUMzQztBQUNGIiwiZmlsZSI6InJhaWYuY3NzIiwic291cmNlc0NvbnRlbnQiOlsiLnJhaWYtbG9hZGVyIHtcbiAgdHJhbnNmb3JtOiByb3RhdGVaKDQ1ZGVnKTtcbiAgcGVyc3BlY3RpdmU6IDEwMDBweDtcbiAgYm9yZGVyLXJhZGl1czogNTAlO1xuICB3aWR0aDogMjVweDtcbiAgaGVpZ2h0OiAyNXB4O1xuICBjb2xvcjogIzM4NzRmZjtcbiAgZGlzcGxheTogaW5saW5lLWJsb2NrO1xufVxuXG4ucmFpZi1sb2FkZXI6YmVmb3JlLFxuLnJhaWYtbG9hZGVyOmFmdGVyIHtcbiAgY29udGVudDogXCJcIjtcbiAgZGlzcGxheTogYmxvY2s7XG4gIHBvc2l0aW9uOiBhYnNvbHV0ZTtcbiAgdG9wOiAwO1xuICBsZWZ0OiAwO1xuICB3aWR0aDogaW5oZXJpdDtcbiAgaGVpZ2h0OiBpbmhlcml0O1xuICBib3JkZXItcmFkaXVzOiA1MCU7XG4gIHRyYW5zZm9ybTogcm90YXRlWCg3MGRlZyk7XG4gIGFuaW1hdGlvbjogMXMgc3BpbiBsaW5lYXIgaW5maW5pdGU7XG59XG5cbi5yYWlmLWxvYWRlcjphZnRlciB7XG4gIGNvbG9yOiAjMjViMDAzO1xuICB0cmFuc2Zvcm06IHJvdGF0ZVkoNzBkZWcpO1xuICBhbmltYXRpb24tZGVsYXk6IDAuNHM7XG59XG5cbi5yYWlmLXN0cmVhbWluZy1jdXJzb3Ige1xuICBkaXNwbGF5OiBpbmxpbmUtYmxvY2s7XG4gIHdpZHRoOiAycHg7XG4gIGhlaWdodDogMS4xZW07XG4gIG1hcmdpbi1ib3R0b206IC0ycHg7XG4gIGJhY2tncm91bmQtY29sb3I6IGN1cnJlbnRDb2xvcjtcbiAgYW5pbWF0aW9uOiBibGluayAxcyBpbmZpbml0ZTtcbiAgdHJhbnNmb3JtOiBub25lO1xuICBib3JkZXItcmFkaXVzOiAwO1xuICBwb3NpdGlvbjogcmVsYXRpdmU7XG59XG5cbi5yYWlmLXN0cmVhbWluZy1jdXJzb3I6YmVmb3JlLFxuLnJhaWYtc3RyZWFtaW5nLWN1cnNvcjphZnRlciB7XG4gIGRpc3BsYXk6IG5vbmU7XG59XG5cbkBrZXlmcmFtZXMgYmxpbmsge1xuICAwJSwgNTAlIHtcbiAgICBvcGFjaXR5OiAxO1xuICB9XG4gIDUxJSwgMTAwJSB7XG4gICAgb3BhY2l0eTogMDtcbiAgfVxufVxuQGtleWZyYW1lcyByb3RhdGUge1xuICAwJSB7XG4gICAgdHJhbnNmb3JtOiB0cmFuc2xhdGUoLTUwJSwgLTUwJSkgcm90YXRlWigwZGVnKTtcbiAgfVxuICAxMDAlIHtcbiAgICB0cmFuc2Zvcm06IHRyYW5zbGF0ZSgtNTAlLCAtNTAlKSByb3RhdGVaKDM2MGRlZyk7XG4gIH1cbn1cbkBrZXlmcmFtZXMgcm90YXRlY2N3IHtcbiAgMCUge1xuICAgIHRyYW5zZm9ybTogdHJhbnNsYXRlKC01MCUsIC01MCUpIHJvdGF0ZSgwZGVnKTtcbiAgfVxuICAxMDAlIHtcbiAgICB0cmFuc2Zvcm06IHRyYW5zbGF0ZSgtNTAlLCAtNTAlKSByb3RhdGUoLTM2MGRlZyk7XG4gIH1cbn1cbkBrZXlmcmFtZXMgc3BpbiB7XG4gIDAlLCAxMDAlIHtcbiAgICBib3gtc2hhZG93OiAwLjNlbSAwcHggMCAwcHggY3VycmVudGNvbG9yO1xuICB9XG4gIDEyJSB7XG4gICAgYm94LXNoYWRvdzogMC4zZW0gMC4zZW0gMCAwIGN1cnJlbnRjb2xvcjtcbiAgfVxuICAyNSUge1xuICAgIGJveC1zaGFkb3c6IDAgMC4zZW0gMCAwcHggY3VycmVudGNvbG9yO1xuICB9XG4gIDM3JSB7XG4gICAgYm94LXNoYWRvdzogLTAuM2VtIDAuM2VtIDAgMCBjdXJyZW50Y29sb3I7XG4gIH1cbiAgNTAlIHtcbiAgICBib3gtc2hhZG93OiAtMC4zZW0gMCAwIDAgY3VycmVudGNvbG9yO1xuICB9XG4gIDYyJSB7XG4gICAgYm94LXNoYWRvdzogLTAuM2VtIC0wLjNlbSAwIDAgY3VycmVudGNvbG9yO1xuICB9XG4gIDc1JSB7XG4gICAgYm94LXNoYWRvdzogMHB4IC0wLjNlbSAwIDAgY3VycmVudGNvbG9yO1xuICB9XG4gIDg3JSB7XG4gICAgYm94LXNoYWRvdzogMC4zZW0gLTAuM2VtIDAgMCBjdXJyZW50Y29sb3I7XG4gIH1cbn1cbiJdfQ== */
@@ -28,6 +28,33 @@
28
28
  animation-delay: .4s;
29
29
  }
30
30
 
31
+ // Streaming cursor - a simple blinking cursor
32
+ .raif-streaming-cursor {
33
+ display: inline-block;
34
+ width: 2px;
35
+ height: 1.1em;
36
+ margin-bottom: -2px;
37
+ background-color: currentColor;
38
+ animation: blink 1s infinite;
39
+ transform: none;
40
+ border-radius: 0;
41
+ position: relative;
42
+ }
43
+
44
+ .raif-streaming-cursor:before,
45
+ .raif-streaming-cursor:after {
46
+ display: none;
47
+ }
48
+
49
+ @keyframes blink {
50
+ 0%, 50% {
51
+ opacity: 1;
52
+ }
53
+ 51%, 100% {
54
+ opacity: 0;
55
+ }
56
+ }
57
+
31
58
  @keyframes rotate {
32
59
  0% {
33
60
  transform: translate(-50%, -50%) rotateZ(0deg);
@@ -49,7 +76,6 @@
49
76
  }
50
77
 
51
78
  @keyframes spin {
52
-
53
79
  0%,
54
80
  100% {
55
81
  box-shadow: .3em 0px 0 0px currentcolor;
@@ -3,6 +3,8 @@
3
3
  module Raif::Concerns::LlmResponseParsing
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ ASCII_CONTROL_CHARS = /[\x00-\x1f\x7f]/
7
+
6
8
  included do
7
9
  normalizes :raw_response, with: ->(text){ text&.strip }
8
10
 
@@ -35,32 +37,36 @@ module Raif::Concerns::LlmResponseParsing
35
37
  # If the response format is HTML, it will be sanitized via ActionController::Base.helpers.sanitize.
36
38
  #
37
39
  # @return [Object] The parsed response.
38
- def parsed_response
40
+ def parsed_response(force_reparse: false)
39
41
  return if raw_response.blank?
42
+ return @parsed_response if @parsed_response.present? && !force_reparse
40
43
 
41
- @parsed_response ||= if response_format_json?
42
- json = raw_response.gsub("```json", "").gsub("```", "")
43
- JSON.parse(json)
44
+ @parsed_response = if response_format_json?
45
+ parse_json_response
44
46
  elsif response_format_html?
45
- html = raw_response.strip.gsub("```html", "").chomp("```")
46
- clean_html_fragment(html)
47
+ parse_html_response
47
48
  else
48
49
  raw_response.strip
49
50
  end
50
51
  end
51
52
 
52
- def clean_html_fragment(html)
53
- fragment = Nokogiri::HTML.fragment(html)
53
+ def parse_json_response
54
+ json = raw_response.gsub(/#{ASCII_CONTROL_CHARS}|^```json|```$/, "").strip
54
55
 
55
- fragment.traverse do |node|
56
- if node.text? && node.text.strip.empty?
57
- node.remove
58
- end
59
- end
56
+ raise JSON::ParserError, "Invalid JSON" if json.blank?
60
57
 
61
- allowed_tags = self.class.allowed_tags || Rails::HTML5::SafeListSanitizer.allowed_tags
62
- allowed_attributes = self.class.allowed_attributes || Rails::HTML5::SafeListSanitizer.allowed_attributes
58
+ JSON.parse(json)
59
+ end
63
60
 
64
- ActionController::Base.helpers.sanitize(fragment.to_html, tags: allowed_tags, attributes: allowed_attributes).strip
61
+ def parse_html_response
62
+ html = raw_response.strip.gsub("```html", "").chomp("```")
63
+
64
+ html_with_converted_links = Raif::Utils::HtmlFragmentProcessor.convert_markdown_links_to_html(html)
65
+ Raif::Utils::HtmlFragmentProcessor.clean_html_fragment(
66
+ html_with_converted_links,
67
+ allowed_tags: allowed_tags,
68
+ allowed_attributes: allowed_attributes
69
+ )
65
70
  end
71
+
66
72
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Anthropic::ToolFormatting
4
+ extend ActiveSupport::Concern
5
+
6
+ def build_tools_parameter(model_completion)
7
+ tools = []
8
+
9
+ # If we're looking for a JSON response, add a tool to the request that the model can use to provide a JSON response
10
+ if model_completion.response_format_json? && model_completion.json_response_schema.present?
11
+ tools << {
12
+ name: "json_response",
13
+ description: "Generate a structured JSON response based on the provided schema.",
14
+ input_schema: model_completion.json_response_schema
15
+ }
16
+ end
17
+
18
+ # If we support native tool use and have tools available, add them to the request
19
+ if supports_native_tool_use? && model_completion.available_model_tools.any?
20
+ model_completion.available_model_tools_map.each do |_tool_name, tool|
21
+ tools << if tool.provider_managed?
22
+ format_provider_managed_tool(tool)
23
+ else
24
+ {
25
+ name: tool.tool_name,
26
+ description: tool.tool_description,
27
+ input_schema: tool.tool_arguments_schema
28
+ }
29
+ end
30
+ end
31
+ end
32
+
33
+ tools
34
+ end
35
+
36
+ def format_provider_managed_tool(tool)
37
+ validate_provider_managed_tool_support!(tool)
38
+
39
+ case tool.name
40
+ when "Raif::ModelTools::ProviderManaged::WebSearch"
41
+ {
42
+ type: "web_search_20250305",
43
+ name: "web_search",
44
+ max_uses: 5
45
+ }
46
+ when "Raif::ModelTools::ProviderManaged::CodeExecution"
47
+ {
48
+ type: "code_execution_20250522",
49
+ name: "code_execution"
50
+ }
51
+ else
52
+ raise Raif::Errors::UnsupportedFeatureError,
53
+ "Invalid provider-managed tool: #{tool.name} for #{key}"
54
+ end
55
+ end
56
+ end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Raif::Concerns::Llms::BedrockClaude::MessageFormatting
3
+ module Raif::Concerns::Llms::Bedrock::MessageFormatting
4
4
  extend ActiveSupport::Concern
5
5
 
6
- def format_string_message(content)
6
+ def format_string_message(content, role: nil)
7
7
  { "text" => content }
8
8
  end
9
9
 
@@ -13,7 +13,7 @@ module Raif::Concerns::Llms::BedrockClaude::MessageFormatting
13
13
  elsif image_input.source_type == :file_content
14
14
  # The AWS Bedrock SDK requires data sent as bytes (and doesn't support base64 like everyone else)
15
15
  # The ModelCompletion stores the messages as JSON though, so it can't be raw bytes (it will throw an encoding error).
16
- # We store the image data as base64 and then it will get converted to bytes in Raif::Llms::BedrockClaude#perform_model_completion!
16
+ # We store the image data as base64 and then it will get converted to bytes in Raif::Llms::Bedrock#perform_model_completion!
17
17
  # before sending to AWS.
18
18
  {
19
19
  "image" => {
@@ -34,7 +34,7 @@ module Raif::Concerns::Llms::BedrockClaude::MessageFormatting
34
34
  elsif file_input.source_type == :file_content
35
35
  # The AWS Bedrock SDK requires data sent as bytes (and doesn't support base64 like everyone else)
36
36
  # The ModelCompletion stores the messages as JSON though, so it can't be raw bytes (it will throw an encoding error).
37
- # We store the image data as base64 and then it will get converted to bytes in Raif::Llms::BedrockClaude#perform_model_completion!
37
+ # We store the image data as base64 and then it will get converted to bytes in Raif::Llms::Bedrock#perform_model_completion!
38
38
  # before sending to AWS.
39
39
  {
40
40
  "document" => {
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Bedrock::ToolFormatting
4
+ extend ActiveSupport::Concern
5
+
6
+ def build_tools_parameter(model_completion)
7
+ tools = []
8
+
9
+ # If we're looking for a JSON response, add a tool to the request that the model can use to provide a JSON response
10
+ if model_completion.response_format_json? && model_completion.json_response_schema.present?
11
+ tools << {
12
+ name: "json_response",
13
+ description: "Generate a structured JSON response based on the provided schema.",
14
+ input_schema: { json: model_completion.json_response_schema }
15
+ }
16
+ end
17
+
18
+ model_completion.available_model_tools_map.each do |_tool_name, tool|
19
+ tools << if tool.provider_managed?
20
+ raise Raif::Errors::UnsupportedFeatureError,
21
+ "Invalid provider-managed tool: #{tool.name} for #{key}"
22
+ else
23
+ {
24
+ name: tool.tool_name,
25
+ description: tool.tool_description,
26
+ input_schema: { json: tool.tool_arguments_schema }
27
+ }
28
+ end
29
+ end
30
+
31
+ return {} if tools.blank?
32
+
33
+ {
34
+ tools: tools.map{|tool| { tool_spec: tool } }
35
+ }
36
+ end
37
+ end
@@ -5,9 +5,10 @@ module Raif::Concerns::Llms::MessageFormatting
5
5
 
6
6
  def format_messages(messages)
7
7
  messages.map do |message|
8
+ role = message["role"] || message[:role]
8
9
  {
9
- "role" => message["role"] || message[:role],
10
- "content" => format_message_content(message["content"] || message[:content])
10
+ "role" => role,
11
+ "content" => format_message_content(message["content"] || message[:content], role: role)
11
12
  }
12
13
  end
13
14
  end
@@ -15,11 +16,11 @@ module Raif::Concerns::Llms::MessageFormatting
15
16
  # Content could be a string or an array.
16
17
  # If it's an array, it could contain Raif::ModelImageInput or Raif::ModelFileInput objects,
17
18
  # which need to be formatted according to each model provider's API.
18
- def format_message_content(content)
19
+ def format_message_content(content, role: nil)
19
20
  raise ArgumentError,
20
21
  "Message content must be an array or a string. Content was: #{content.inspect}" unless content.is_a?(Array) || content.is_a?(String)
21
22
 
22
- return [format_string_message(content)] if content.is_a?(String)
23
+ return [format_string_message(content, role: role)] if content.is_a?(String)
23
24
 
24
25
  content.map do |item|
25
26
  if item.is_a?(Raif::ModelImageInput)
@@ -27,14 +28,14 @@ module Raif::Concerns::Llms::MessageFormatting
27
28
  elsif item.is_a?(Raif::ModelFileInput)
28
29
  format_model_file_input_message(item)
29
30
  elsif item.is_a?(String)
30
- format_string_message(item)
31
+ format_string_message(item, role: role)
31
32
  else
32
33
  item
33
34
  end
34
35
  end
35
36
  end
36
37
 
37
- def format_string_message(content)
38
+ def format_string_message(content, role: nil)
38
39
  { "type" => "text", "text" => content }
39
40
  end
40
41