raif 1.1.0 → 1.2.1.pre

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +150 -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 +44 -44
  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 +24 -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 +169 -47
  61. data/lib/raif/migration_checker.rb +74 -0
  62. data/lib/raif/utils/html_fragment_processor.rb +170 -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. data/spec/support/complex_test_tool.rb +65 -0
  67. data/spec/support/rspec_helpers.rb +66 -0
  68. data/spec/support/test_conversation.rb +18 -0
  69. data/spec/support/test_embedding_model.rb +27 -0
  70. data/spec/support/test_llm.rb +22 -0
  71. data/spec/support/test_model_tool.rb +32 -0
  72. data/spec/support/test_task.rb +45 -0
  73. metadata +52 -8
  74. 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: 1fb384de50e5d129f3cc88fd56ba12f8a45c5c25061b8dda631f3ce7593a681b
4
+ data.tar.gz: 4805d6a421cdff32a2ec857b53ced8e7244b32f918cda927bace04693d840771
5
5
  SHA512:
6
- metadata.gz: d9dd7273eeccb284d7ee720fe586dc1e86b6e3355da378a719310cbe3209c508a57a3ee1cfcaa60762cd2860f76960b4f89aa39d7f5eafa90eb97b78adab6123
7
- data.tar.gz: 26013bb1beb60367d878b451163594c50e36ef046b8ae0864a4728c4d0ae862e79ec22ab5ccb6c0acd88991b9f227b3af4b31daedc76e90755e9d87bce45f25c
6
+ metadata.gz: fb2cb0bda00b00cbee10a08d8a634a7a06276ab555cfd7a224ea51d6ab800effc7fbaedb6ba2642687632c705bc4ee953bc0b74316431bfc2ba1e72e091aba7f
7
+ data.tar.gz: b4dc7c5b059054b5252f1d676f8dfa67253de3548da9a5c4fae813f675a77634d6bd4da477ae26129cebdd6515cb7c09de389445ec80f6bb69f89b10d620755c
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
 
@@ -144,6 +195,8 @@ Currently included OpenRouter models:
144
195
  - `open_router_claude_3_7_sonnet`
145
196
  - `open_router_llama_3_3_70b_instruct`
146
197
  - `open_router_llama_3_1_8b_instruct`
198
+ - `open_router_llama_4_maverick`
199
+ - `open_router_llama_4_scout`
147
200
  - `open_router_gemini_2_0_flash`
148
201
  - `open_router_deepseek_chat_v3`
149
202
 
@@ -184,6 +237,38 @@ puts model_completion.parsed_response # will strip backticks, parse the JSON, an
184
237
  # => {"joke" => "Why don't skeletons fight each other? They don't have the guts."}
185
238
  ```
186
239
 
240
+ ## Streaming Responses
241
+
242
+ 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:
243
+
244
+ ```ruby
245
+ llm = Raif.llm(:open_ai_gpt_4o)
246
+ model_completion = llm.chat(message: "Tell me a story") do |model_completion, delta, sse_event|
247
+ # This block is called multiple times as the response streams in.
248
+ # You could broadcast these updates via Turbo Streams, WebSockets, etc.
249
+ Turbo::StreamsChannel.broadcast_replace_to(
250
+ :my_channel,
251
+ target: "chat-response",
252
+ partial: "my_partial_displaying_chat_response",
253
+ locals: { model_completion: model_completion, delta: delta, sse_event: sse_event }
254
+ )
255
+ end
256
+
257
+ # The final complete response is available in the model_completion
258
+ puts model_completion.raw_response
259
+ ```
260
+
261
+ You can configure the streaming update frequency by adjusting the chunk size threshold in your Raif configuration:
262
+
263
+ ```ruby
264
+ Raif.configure do |config|
265
+ # Control how often the model completion is updated & the block is called when streaming.
266
+ # Lower values = more frequent updates but more database writes.
267
+ # Higher values = less frequent updates but fewer database writes.
268
+ config.streaming_update_chunk_size_threshold = 50 # default is 25
269
+ end
270
+ ```
271
+
187
272
  # Key Raif Concepts
188
273
 
189
274
  ## Tasks
@@ -334,6 +419,10 @@ If your app already includes Bootstrap styles, this will render a conversation i
334
419
 
335
420
  If your app does not include Bootstrap, you can [override the views](#views) to update styles.
336
421
 
422
+ ### Real-time Streaming Responses
423
+
424
+ 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.
425
+
337
426
  ### Conversation Types
338
427
 
339
428
  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 +609,54 @@ class Raif::ModelTools::GoogleSearch < Raif::ModelTool
520
609
  end
521
610
  ```
522
611
 
523
- ## Images/Files/PDF's
612
+ ### Provider-Managed Tools
613
+
614
+ 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:
615
+
616
+ - **`Raif::ModelTools::ProviderManaged::WebSearch`**: Performs real-time web searches and returns relevant results
617
+ - **`Raif::ModelTools::ProviderManaged::CodeExecution`**: Executes code in a secure sandboxed environment (e.g. Python)
618
+ - **`Raif::ModelTools::ProviderManaged::ImageGeneration`**: Generates images based on text descriptions
619
+
620
+ Current provider-managed tool support:
621
+ | Provider | WebSearch | CodeExecution | ImageGeneration |
622
+ |----------|-----------|---------------|-----------------|
623
+ | OpenAI Responses API | ✅ | ✅ | ✅ |
624
+ | OpenAI Completions API | ❌ | ❌ | ❌ |
625
+ | Anthropic Claude | ✅ | ✅ | ❌ |
626
+ | AWS Bedrock (Claude) | ❌ | ❌ | ❌ |
627
+ | OpenRouter | ❌ | ❌ | ❌ |
628
+
629
+ To use provider-managed tools, include them in the `available_model_tools` array:
630
+
631
+ ```ruby
632
+ # In a conversation
633
+ conversation = Raif::Conversation.create!(
634
+ creator: current_user,
635
+ available_model_tools: [
636
+ "Raif::ModelTools::ProviderManaged::WebSearch",
637
+ "Raif::ModelTools::ProviderManaged::CodeExecution"
638
+ ]
639
+ )
640
+
641
+ # In an agent
642
+ agent = Raif::Agents::ReActAgent.new(
643
+ task: "Search for recent news about AI and create a summary chart",
644
+ available_model_tools: [
645
+ "Raif::ModelTools::ProviderManaged::WebSearch",
646
+ "Raif::ModelTools::ProviderManaged::CodeExecution"
647
+ ],
648
+ creator: current_user
649
+ )
650
+
651
+ # Directly in a chat
652
+ llm = Raif.llm(:open_ai_responses_gpt_4_1)
653
+ model_completion = llm.chat(
654
+ messages: [{ role: "user", content: "What are the latest developments in Ruby on Rails?" }],
655
+ available_model_tools: [Raif::ModelTools::ProviderManaged::WebSearch]
656
+ )
657
+ ```
658
+
659
+ ## Sending Images/Files/PDF's to the LLM
524
660
 
525
661
  Raif supports images, files, and PDF's in the messages sent to the LLM.
526
662
 
@@ -596,7 +732,7 @@ Raif supports generation of vector embeddings. You can enable and configure embe
596
732
  ```ruby
597
733
  Raif.configure do |config|
598
734
  config.open_ai_embedding_models_enabled = true
599
- config.aws_bedrock_titan_embedding_models_enabled = true
735
+ config.bedrock_embedding_models_enabled = true
600
736
 
601
737
  config.default_embedding_model_key = "open_ai_text_embedding_3_small"
602
738
  end
@@ -649,6 +785,7 @@ The admin interface contains sections for:
649
785
  - Conversations
650
786
  - Agents
651
787
  - Model Tool Invocations
788
+ - Stats
652
789
 
653
790
 
654
791
  ### Model Completions
@@ -670,6 +807,9 @@ The admin interface contains sections for:
670
807
  ![Model Tool Invocations Index](./screenshots/admin-model-tool-invocations-index.png)
671
808
  ![Model Tool Invocation Detail](./screenshots/admin-model-tool-invocation-show.png)
672
809
 
810
+ ### Stats
811
+ ![Stats](./screenshots/admin-stats.png)
812
+
673
813
  # Customization
674
814
 
675
815
  ## Controllers
@@ -832,6 +972,12 @@ You can then access the app at [http://localhost:3000](http://localhost:3000).
832
972
 
833
973
  ![Demo App Screenshot](./screenshots/demo-app.png)
834
974
 
975
+ # Contributing
976
+
977
+ We welcome contributions to Raif! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
978
+
979
+ **Important**: All PR's should be made against the `dev` branch.
980
+
835
981
  # License
836
982
 
837
983
  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