ruby_llm 2.0.0 → 2.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -10,6 +10,10 @@ module RubyLLM
10
10
  module Tools
11
11
  MULTIMODAL_FUNCTION_RESPONSE_GENERATION = Gem::Version.new('3')
12
12
 
13
+ # The signature Google documents for a function call Gemini did not
14
+ # make, such as one from another provider. Vertex AI accepts no other.
15
+ PLACEHOLDER_SIGNATURE = 'skip_thought_signature_validator'
16
+
13
17
  def format_tools(tools)
14
18
  return [] if tools.empty?
15
19
 
@@ -18,14 +22,13 @@ module RubyLLM
18
22
  }]
19
23
  end
20
24
 
21
- def format_tool_call(msg) # rubocop:disable Metrics/PerceivedComplexity
25
+ # Gemini signs only the first of parallel calls, so each call goes back
26
+ # with its own signature or none, never one another part carried.
27
+ def format_tool_call(msg)
22
28
  parts = []
23
29
 
24
30
  parts.concat(Media.format_content(msg.content, msg.attachments)) if msg.content && !msg.content.empty?
25
31
 
26
- fallback_signature = msg.thinking&.signature
27
- used_fallback = false
28
-
29
32
  msg.tool_calls.each_value do |tool_call|
30
33
  part = {
31
34
  functionCall: {
@@ -33,13 +36,7 @@ module RubyLLM
33
36
  args: tool_call.arguments
34
37
  }
35
38
  }
36
-
37
- signature = tool_call.thought_signature
38
- if signature.nil? && fallback_signature && !used_fallback
39
- signature = fallback_signature
40
- used_fallback = true
41
- end
42
- part[:thoughtSignature] = signature if signature
39
+ part[:thoughtSignature] = tool_call.thought_signature if tool_call.thought_signature
43
40
  parts << part
44
41
  end
45
42
 
@@ -95,6 +92,15 @@ module RubyLLM
95
92
 
96
93
  private
97
94
 
95
+ # Gemini 3 refuses a step of the current turn whose first function
96
+ # call carries no signature.
97
+ def sign_step(parts)
98
+ first_call = parts.find { |part| part.key?(:functionCall) }
99
+ return parts if first_call.nil? || first_call.key?(:thoughtSignature)
100
+
101
+ parts.map { |part| part.equal?(first_call) ? part.merge(thoughtSignature: PLACEHOLDER_SIGNATURE) : part }
102
+ end
103
+
98
104
  # functionResponse.parts only accepts inline bytes, and pre-Gemini 3 models reject it
99
105
  def partition_tool_result_attachments(attachments)
100
106
  return [[], []] if attachments.empty?
@@ -75,6 +75,7 @@ module RubyLLM
75
75
  def parse_transcription_response(response, model:)
76
76
  data = response.body
77
77
  text = extract_text(data)
78
+ raise_if_transcription_blocked(data, text, response)
78
79
 
79
80
  usage = extract_usage(data)
80
81
 
@@ -86,6 +87,18 @@ module RubyLLM
86
87
  )
87
88
  end
88
89
 
90
+ # Gemini returns no candidates only for a prompt it blocked.
91
+ def raise_if_transcription_blocked(data, text, response)
92
+ return unless text.to_s.empty? && data.is_a?(Hash)
93
+
94
+ reason = data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
95
+ return unless data.dig('candidates', 0).nil? || normalize_finish_reason(reason) == :content_filter
96
+
97
+ usage = extract_usage(data)
98
+ @usage_tracker&.observe_tokens(Tokens.new(input: usage[:input_tokens], output: usage[:output_tokens]))
99
+ raise ContentFilterError.new(['Gemini blocked the transcription', reason].compact.join(': '), response:)
100
+ end
101
+
89
102
  def extract_text(data)
90
103
  candidate = data.is_a?(Hash) ? data.dig('candidates', 0) : nil
91
104
  return unless candidate
@@ -18,11 +18,16 @@ module RubyLLM
18
18
  Support::Utils.deep_merge({ instances: [instance] }, provider_options)
19
19
  end
20
20
 
21
- def parse_video_job(response, model:)
21
+ def parse_video_job(response, model:, **request)
22
22
  name = response.body['name']
23
23
  raise Error.new('Gemini did not return a video generation operation', response:) unless name
24
24
 
25
- VideoJob.new(id: name, protocol: self, model: model, raw: response.body)
25
+ VideoJob.new(id: name, protocol: self, model: model, raw: response.body, **request)
26
+ end
27
+
28
+ def video_request_settings(request)
29
+ parameters = request[:parameters] || {}
30
+ { duration: parameters[:durationSeconds], resolution: parameters[:resolution] }
26
31
  end
27
32
 
28
33
  def render_video_extension_payload(prompt, extend:, provider_options: {}, **)
@@ -11,6 +11,7 @@ module RubyLLM
11
11
  include Gemini::Videos
12
12
  include Gemini::Media
13
13
  include Gemini::Models
14
+ include Gemini::RequestShapes
14
15
  include Gemini::Streaming
15
16
  include Gemini::Tools
16
17
  include Gemini::Speech
@@ -10,8 +10,6 @@ module RubyLLM
10
10
  end
11
11
 
12
12
  def render_video_payload(prompt, model:, with: [], provider_options: {})
13
- raise ArgumentError, 'vLLM-Omni video generation requires a prompt' if prompt.nil?
14
-
15
13
  options = video_options(provider_options)
16
14
  payload = { model: model, prompt: prompt }.merge(video_references(with)).merge(options).compact
17
15
  payload.transform_values do |value|
@@ -25,12 +23,16 @@ module RubyLLM
25
23
  end
26
24
  end
27
25
 
28
- def parse_video_job(response, model:)
26
+ def parse_video_job(response, model:, **request)
29
27
  body = response.body
30
28
  id = body['id']
31
29
  raise Error.new('GPUStack did not return a video job id', response:) unless id
32
30
 
33
- VideoJob.new(id: id, protocol: self, model: body['model'] || model, **video_job_state(body))
31
+ VideoJob.new(id: id, protocol: self, model: body['model'] || model, **video_job_state(body), **request)
32
+ end
33
+
34
+ def video_request_settings(request)
35
+ { duration: request[:seconds], resolution: request[:size] }
34
36
  end
35
37
 
36
38
  def video_job_url(job)
@@ -60,7 +62,7 @@ module RubyLLM
60
62
 
61
63
  def video_references(attachments)
62
64
  attachments.group_by(&:type).to_h do |type, group|
63
- values = group.map { |attachment| { "#{type}_url" => video_reference(attachment) } }
65
+ values = group.map { |attachment| { "#{type}_url" => attachment.for_llm } }
64
66
  [:"#{type}_reference", values.one? ? values.first : values]
65
67
  end
66
68
  end
@@ -86,10 +88,6 @@ module RubyLLM
86
88
  raise UnsupportedAttachmentError, attachment.mime_type
87
89
  end
88
90
  end
89
-
90
- def video_reference(attachment)
91
- attachment.url? ? attachment.source.to_s : attachment.for_llm
92
- end
93
91
  end
94
92
  end
95
93
  end
@@ -5,6 +5,7 @@ module RubyLLM
5
5
  class Interactions
6
6
  module Chat # :nodoc:
7
7
  FINISH_REASONS = { 'completed' => :stop, 'requires_action' => :tool_calls, 'incomplete' => :max_tokens }.freeze
8
+ SERVER_TOOL_USAGE_NAMES = { 'google_search' => 'web_search' }.freeze
8
9
 
9
10
  module_function
10
11
 
@@ -34,9 +35,6 @@ module RubyLLM
34
35
  raise ArgumentError, 'Gemini Interactions does not expose a thinking-off control'
35
36
  end
36
37
  raise ArgumentError, 'Gemini Interactions accepts thinking effort, not a token budget' if thinking.budget
37
- if thinking.effort && !%i[minimal low medium high].include?(thinking.effort)
38
- raise ArgumentError, 'Gemini Interactions thinking effort must be minimal, low, medium, or high'
39
- end
40
38
 
41
39
  { thinking_level: thinking.effort&.to_s,
42
40
  thinking_summaries: render_interaction_summaries(thinking.display) }.compact
@@ -50,20 +48,25 @@ module RubyLLM
50
48
  display == :omitted ? 'none' : 'auto'
51
49
  end
52
50
 
51
+ # Gemini checks signatures only in the current turn, which starts at
52
+ # the last user message.
53
53
  def format_interaction_input(messages)
54
54
  calls = messages.flat_map { |message| message.tool_calls.to_h.values }.to_h { |call| [call.id, call] }
55
- messages.reject { |message| message.role == :system }.flat_map do |message|
56
- render_interaction_turn(message, calls)
55
+ turn_start = messages.rindex { |message| message.role == :user }
56
+ messages.each_with_index.flat_map do |message, index|
57
+ next [] if message.role == :system
58
+
59
+ render_interaction_turn(message, calls, current: turn_start.nil? || index > turn_start)
57
60
  end
58
61
  end
59
62
 
60
- def render_interaction_turn(message, calls)
63
+ def render_interaction_turn(message, calls, current:)
61
64
  if interaction_state?(message.raw_content)
62
65
  render_interaction_history(message.raw_content.dig('response', 'steps') || [])
63
66
  elsif message.tool_result?
64
67
  [render_interaction_result(message, calls)]
65
68
  else
66
- render_interaction_message(message)
69
+ render_interaction_message(message, current:)
67
70
  end
68
71
  end
69
72
 
@@ -73,19 +76,24 @@ module RubyLLM
73
76
  end
74
77
 
75
78
  def render_interaction_history(steps)
79
+ answered = interaction_answered_calls(steps)
76
80
  steps.map do |step|
77
- step['type'].to_s.start_with?('mcp_server_') ? step.except('signature') : step
81
+ if step['type'].to_s.start_with?('mcp_server_') || answered.include?(step['id'] || step['call_id'])
82
+ step.except('signature')
83
+ else
84
+ step
85
+ end
78
86
  end
79
87
  end
80
88
 
81
- def render_interaction_message(message)
89
+ def render_interaction_message(message, current:)
82
90
  steps = []
83
- if message.content || message.attachments.any?
84
- steps << { type: message.role == :assistant ? 'model_output' : 'user_input',
85
- content: render_interaction_content(message.content, message.attachments) }
86
- end
87
- message.tool_calls&.each_value do |call|
88
- steps << { type: 'function_call', id: call.id, name: call.name, arguments: call.arguments }
91
+ content = render_interaction_content(message.content, message.attachments)
92
+ steps << { type: message.role == :assistant ? 'model_output' : 'user_input', content: } if content.any?
93
+ message.tool_calls.to_h.values.each_with_index do |call, position|
94
+ signature = call.thought_signature || (Gemini::Tools::PLACEHOLDER_SIGNATURE if current && position.zero?)
95
+ steps << { type: 'function_call', id: call.id, name: call.name, arguments: call.arguments,
96
+ signature: }.compact
89
97
  end
90
98
  steps
91
99
  end
@@ -104,19 +112,39 @@ module RubyLLM
104
112
  steps = data.fetch('steps', [])
105
113
  content = parse_interaction_content(steps)
106
114
  calls = parse_interaction_calls(steps)
107
- if data['status'] == 'requires_action' && calls.empty?
115
+ status = interaction_status(data, calls, steps)
116
+ if status == 'requires_action' && calls.empty?
108
117
  raise Error.new('Gemini interaction requires an unsupported action', response: raw)
109
118
  end
110
119
 
111
120
  Message.new(role: :assistant, content: content[:text], attachments: content[:attachments],
112
121
  citations: content[:citations], thinking: parse_interaction_thinking(steps),
113
122
  tool_calls: calls, server_tool_calls: parse_interaction_server_calls(steps),
114
- raw_content: { 'response' => data },
123
+ raw_content: kept_interaction(data, steps),
115
124
  model: model, raw: raw, cost: cost,
116
- finish_reason: interaction_finish_reason(data['status'], calls),
125
+ finish_reason: interaction_finish_reason(status, calls),
126
+ server_tool_use: parse_interaction_server_tool_use(data['usage'] || {}),
117
127
  **parse_interaction_usage(data['usage'] || {}))
118
128
  end
119
129
 
130
+ def interaction_status(data, calls, steps)
131
+ return data['status'] unless data['status'] == 'requires_action' && calls.empty?
132
+ return data['status'] if interaction_answered_calls(steps).empty?
133
+
134
+ steps.any? { |step| step['type'] == 'model_output' } ? 'completed' : data['status']
135
+ end
136
+
137
+ def parse_interaction_server_tool_use(usage)
138
+ Array(usage['grounding_tool_count']).to_h do |grounding|
139
+ tool = grounding['type']
140
+ ["#{SERVER_TOOL_USAGE_NAMES.fetch(tool, tool)}_requests", grounding['count']]
141
+ end
142
+ end
143
+
144
+ def kept_interaction(data, steps)
145
+ { 'response' => data.merge('steps' => steps.map { |step| without_search_suggestions(step) }) }
146
+ end
147
+
120
148
  def interaction_finish_reason(status, calls)
121
149
  return :tool_calls unless calls.empty?
122
150
 
@@ -11,15 +11,22 @@ module RubyLLM
11
11
  module_function
12
12
 
13
13
  def render_interaction_content(text, attachments)
14
- parts = text.nil? ? [] : [{ type: 'text', text: text.to_s }]
14
+ parts = text.to_s.empty? ? [] : [{ type: 'text', text: text.to_s }]
15
15
  parts + attachments.map { |attachment| render_interaction_attachment(attachment) }
16
16
  end
17
17
 
18
+ def supported_attachment?(attachment)
19
+ attachment.text? || !interaction_attachment_type(attachment).nil?
20
+ end
21
+
18
22
  def render_interaction_attachment(attachment)
23
+ unless supported_attachment?(attachment)
24
+ raise ArgumentError, "Gemini Interactions does not support #{attachment.mime_type} input"
25
+ end
26
+
19
27
  return { type: 'text', text: attachment.content } if attachment.text?
20
28
 
21
29
  type = interaction_attachment_type(attachment)
22
- raise ArgumentError, "Gemini Interactions does not support #{attachment.mime_type} input" unless type
23
30
 
24
31
  part = { type: type, mime_type: attachment.mime_type }
25
32
  if attachment.provider_file?
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Interactions
6
+ # Describes Interactions requests as RequestShape objects. Each input
7
+ # step is a turn. A function result answers its call by id, and like
8
+ # Gemini, the first call of each step in the current turn needs a
9
+ # signature.
10
+ module RequestShapes
11
+ SPEAKERS = { 'user_input' => :user, 'function_result' => :tool }.freeze
12
+ MEDIA_KINDS = { 'image' => :image, 'audio' => :audio, 'video' => :video, 'document' => :document }.freeze
13
+
14
+ def parse_request_shape(payload)
15
+ input = payload['input']
16
+ return unless input.is_a?(Array)
17
+
18
+ turns = input.each_with_index.map do |entry, index|
19
+ entry = shape_hash(entry)
20
+ shape_turn(index, entry['type'], SPEAKERS.fetch(entry['type'], :model), shape_interaction_entry(entry))
21
+ end
22
+ shape_request(turns, payload:, rules: %i[signed_steps],
23
+ instructions: shape_instructions(payload['system_instruction']),
24
+ tool_names: shape_list(payload['tools']).map { |tool| shape_tool_name(tool) },
25
+ thinking_settings: shape_settings(payload['generation_config'], 'thinking_level',
26
+ 'thinking_summaries'))
27
+ end
28
+
29
+ private
30
+
31
+ def shape_interaction_entry(entry)
32
+ signed = shape_signature?(entry['signature'])
33
+ case entry['type']
34
+ when 'user_input', 'model_output' then shape_interaction_content(entry['content'])
35
+ when 'function_call' then [shape_tool_call(entry['name'], entry['arguments'], id: entry['id'], signed:)]
36
+ when 'function_result'
37
+ shape_tool_result(entry['result'], name: entry['name'], id: entry['call_id']) do |part|
38
+ shape_interaction_part(part)
39
+ end
40
+ when 'thought' then [shape_summary(entry['summary'], signed:)]
41
+ else [shape_other(entry['type'], signed:)]
42
+ end
43
+ end
44
+
45
+ def shape_interaction_content(content)
46
+ return [shape_text(content)] if content.is_a?(String)
47
+
48
+ shape_list(content).map { |part| shape_interaction_part(shape_hash(part)) }
49
+ end
50
+
51
+ def shape_interaction_part(part)
52
+ kind = MEDIA_KINDS[part['type']]
53
+ return shape_text(part['text']) if part['type'] == 'text'
54
+ return shape_other(part['type']) unless kind
55
+ return shape_inline(part['data'], kind:, mime_type: part['mime_type']) if part.key?('data')
56
+
57
+ shape_url(part['uri'], kind:, mime_type: part['mime_type'])
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -4,6 +4,13 @@ module RubyLLM
4
4
  module Protocols
5
5
  class Interactions
6
6
  module Streaming # :nodoc:
7
+ ERROR_STATUSES = {
8
+ 'invalid_request' => 400, 'failed_precondition' => 400, 'authentication' => 401,
9
+ 'payment_required' => 402, 'permission_denied' => 403, 'not_found' => 404,
10
+ 'rate_limit_exceeded' => 429, 'quota_exceeded' => 429, 'too_many_requests' => 429,
11
+ 'api_error' => 500, 'service_unavailable' => 503, 'deadline_exceeded' => 504
12
+ }.freeze
13
+
7
14
  module_function
8
15
 
9
16
  def stream_response(payload, additional_headers = {})
@@ -78,6 +85,14 @@ module RubyLLM
78
85
  @interaction_response.merge('steps' => steps)
79
86
  end
80
87
 
88
+ def parse_streaming_error(data)
89
+ error = JSON.parse(data)
90
+ error = error['error'] if error.is_a?(Hash)
91
+ return super unless error.is_a?(Hash)
92
+
93
+ [ERROR_STATUSES.fetch(error['code'], 500), error['message']]
94
+ end
95
+
81
96
  def final_interaction_chunk
82
97
  message = parse_completion_body(streamed_interaction, raw: nil)
83
98
  Chunk.new(role: :assistant, content: nil, model: message.model, tokens: message.tokens,
@@ -23,13 +23,20 @@ module RubyLLM
23
23
  end
24
24
 
25
25
  def parse_interaction_calls(steps)
26
- steps.select { |step| step['type'] == 'function_call' }.to_h do |step|
26
+ answered = interaction_answered_calls(steps)
27
+ steps.select { |step| step['type'] == 'function_call' && !answered.include?(step['id']) }.to_h do |step|
27
28
  [step.fetch('id'), ToolCall.new(id: step.fetch('id'), name: step.fetch('name'),
28
29
  arguments: parse_interaction_arguments(step['arguments']),
29
30
  thought_signature: step['signature'])]
30
31
  end
31
32
  end
32
33
 
34
+ def interaction_answered_calls(steps)
35
+ calls = steps.filter_map { |step| step['id'] if step['type'] == 'function_call' }
36
+ results = steps.filter_map { |step| step['call_id'] if step['type'] == 'function_result' }
37
+ calls & results
38
+ end
39
+
33
40
  def parse_interaction_arguments(arguments)
34
41
  return {} if arguments.nil? || (arguments.is_a?(String) && arguments.empty?)
35
42
 
@@ -39,14 +46,31 @@ module RubyLLM
39
46
  end
40
47
 
41
48
  def parse_interaction_server_calls(steps)
49
+ answered = interaction_answered_calls(steps)
42
50
  steps.filter_map do |step|
43
51
  type = step['type'].to_s
44
- next unless type.end_with?('_call', '_result') && !type.start_with?('function_')
52
+ next unless type.end_with?('_call', '_result')
53
+ next if type.start_with?('function_') && !answered.include?(step['id'] || step['call_id'])
45
54
 
46
- ServerToolCall.new(type: type, id: step['id'] || step['call_id'], name: step['name'],
47
- input: step['arguments'], result: step['result'], raw: step)
55
+ kept = without_search_suggestions(step)
56
+ ServerToolCall.new(type: type, id: kept['id'] || kept['call_id'], name: kept['name'],
57
+ input: kept['arguments'], result: kept['result'], raw: kept,
58
+ search_suggestions: search_suggestions(step))
48
59
  end
49
60
  end
61
+
62
+ # Google's terms forbid storing search suggestions, and a replayed
63
+ # google_search_result keeps the shape it needs without them.
64
+ def without_search_suggestions(step)
65
+ return step unless step['type'] == 'google_search_result'
66
+
67
+ step.merge('result' => Array(step['result']).map { |item| item.except('search_suggestions') })
68
+ end
69
+
70
+ def search_suggestions(step)
71
+ suggestions = Array(step['result']).filter_map { |item| item['search_suggestions'] if item.is_a?(Hash) }
72
+ suggestions.join unless suggestions.empty?
73
+ end
50
74
  end
51
75
  end
52
76
  end
@@ -24,17 +24,7 @@ module RubyLLM
24
24
  payload = { model:, store: false,
25
25
  input: [{ type: 'audio', mime_type: attachment.mime_type, data: attachment.encoded }],
26
26
  generation_config: { transcription_config: config } }
27
- payload = Support::Utils.deep_merge(payload, provider_options)
28
- validate_transcription_config(payload.dig(:generation_config, :transcription_config))
29
- payload
30
- end
31
-
32
- def validate_transcription_config(config)
33
- mode = config[:mode]
34
- return unless config[:custom_vocabulary] && mode.is_a?(Hash)
35
- return unless mode[:diarization_mode] || mode[:timestamp_granularities]
36
-
37
- raise ArgumentError, 'Gemini custom vocabulary cannot be combined with diarization or word timestamps'
27
+ Support::Utils.deep_merge(payload, provider_options)
38
28
  end
39
29
 
40
30
  def parse_transcription_response(response, model:)
@@ -6,6 +6,7 @@ module RubyLLM
6
6
  class Interactions < Protocol
7
7
  include Interactions::Chat
8
8
  include Interactions::Content
9
+ include Interactions::RequestShapes
9
10
  include Interactions::Tools
10
11
  include Interactions::Streaming
11
12
  include Interactions::Transcription
@@ -18,18 +18,13 @@ module RubyLLM
18
18
 
19
19
  private
20
20
 
21
- def render_embedding_payload(text, model:, dimensions:, provider_options:, task_type: nil)
21
+ def render_embedding_payload(text, dimensions:, provider_options:, task_type: nil, **)
22
22
  payload = {
23
23
  input_type: task_type || 'search_document'
24
24
  }
25
25
  texts = [text].flatten.compact.map(&:to_s).reject(&:empty?)
26
26
  payload[:texts] = texts unless texts.empty?
27
-
28
- if dimensions
29
- raise Error, "#{model} does not support custom dimensions" unless cohere_v4?(model)
30
-
31
- payload[:output_dimension] = dimensions
32
- end
27
+ payload[:output_dimension] = dimensions if dimensions
33
28
 
34
29
  deep_merge_provider_options(payload, provider_options)
35
30
  end
@@ -41,10 +36,6 @@ module RubyLLM
41
36
 
42
37
  Embedding.new(vectors:, model:)
43
38
  end
44
-
45
- def cohere_v4?(model)
46
- model.to_s.include?('cohere.embed-v4')
47
- end
48
39
  end
49
40
  end
50
41
  end
@@ -17,8 +17,8 @@ module RubyLLM
17
17
  requests.each do |request|
18
18
  values = request.fetch(:text)
19
19
  values = [values] unless values.is_a?(Array)
20
- unless values.any? && values.all? { |value| value.is_a?(String) && !value.empty? }
21
- raise ArgumentError, 'Bedrock embedding batches require nonempty text strings'
20
+ unless values.any? && values.all?(String)
21
+ raise ArgumentError, 'Bedrock embedding batches require a nonempty list of text strings'
22
22
  end
23
23
  unless request.fetch(:payload).key?(:inputText)
24
24
  raise ArgumentError, 'Bedrock embedding batches require Titan embedding requests'
@@ -11,7 +11,6 @@ module RubyLLM
11
11
  INPAINT_MODEL = 'stability.stable-image-inpaint-v1:0'
12
12
  MODELS = (GENERATION_MODELS + [INPAINT_MODEL]).freeze
13
13
  ASPECT_RATIOS = %w[16:9 1:1 21:9 2:3 3:2 4:5 5:4 9:16 9:21].freeze
14
- IMAGE_TYPES = %w[image/jpeg image/png image/webp].freeze
15
14
 
16
15
  def paint(prompt, model:, size:, count: nil, with: nil, mask: nil, provider_options: {})
17
16
  track_usage(:image) do
@@ -39,10 +38,7 @@ module RubyLLM
39
38
  end
40
39
 
41
40
  images.map do |data|
42
- mime_type = RubyLLM::Files::MimeType.for(StringIO.new(Base64.decode64(data)))
43
- raise Error.new('Bedrock returned invalid image data', response:) unless IMAGE_TYPES.include?(mime_type)
44
-
45
- Image.new(data:, model:, mime_type:)
41
+ Image.new(data:, model:, mime_type: RubyLLM::Files::MimeType.for(StringIO.new(Base64.decode64(data))))
46
42
  end
47
43
  end
48
44
 
@@ -71,7 +67,7 @@ module RubyLLM
71
67
  end
72
68
 
73
69
  def encoded_image(attachment)
74
- raise UnsupportedAttachmentError, attachment.mime_type unless IMAGE_TYPES.include?(attachment.mime_type)
70
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
75
71
 
76
72
  attachment.encoded
77
73
  end
@@ -23,14 +23,8 @@ module RubyLLM
23
23
  # The G1 and V1 models take inputText alone; Bedrock rejects the V2
24
24
  # tuning keys as extraneous.
25
25
  def render_embedding_payload(text, model:, dimensions:, provider_options:, **)
26
- payload = { inputText: text.to_s }
27
-
28
- if titan_v2?(model)
29
- payload[:dimensions] = dimensions if dimensions
30
- payload[:normalize] = true
31
- elsif dimensions
32
- raise Error, "#{model} does not support custom dimensions"
33
- end
26
+ payload = { inputText: text.to_s, dimensions: }.compact
27
+ payload[:normalize] = true if titan_v2?(model)
34
28
 
35
29
  deep_merge_provider_options(payload, provider_options)
36
30
  end
@@ -10,10 +10,7 @@ module RubyLLM
10
10
  private
11
11
 
12
12
  def signed_post(url, payload, additional_headers = {})
13
- body = JSON.generate(payload)
14
-
15
- @connection.post(url, payload, usage: @usage_tracker) do |req|
16
- req.headers.merge!(@provider.sign_headers('POST', url, body))
13
+ @connection.post(url, JSON.generate(payload), usage: @usage_tracker) do |req|
17
14
  req.headers.merge!(additional_headers) unless additional_headers.empty?
18
15
  end
19
16
  end
@@ -11,10 +11,23 @@ module RubyLLM
11
11
  output.select { |entry| entry['type'] == 'message.output' }.each do |entry|
12
12
  parse_conversation_parts(entry['content'], result)
13
13
  end
14
+ result[:attachments].concat(output.filter_map do |entry|
15
+ next unless entry['type'] == 'tool.execution' && entry['name'] == 'image_generation'
16
+
17
+ parse_generated_image(entry.dig('info', 'result'))
18
+ end)
19
+ result[:attachments].uniq!(&:source)
14
20
  result[:thinking] = nil if result[:thinking].empty?
15
21
  result
16
22
  end
17
23
 
24
+ def parse_generated_image(result)
25
+ return unless result
26
+
27
+ url = JSON.parse(result)['url']
28
+ Attachment.new(url, config: @config) if url
29
+ end
30
+
18
31
  def parse_conversation_parts(content, result)
19
32
  return result[:text] << content if content.is_a?(String)
20
33
 
@@ -5,6 +5,10 @@ module RubyLLM
5
5
  module Mistral
6
6
  class Conversations
7
7
  module Chat # :nodoc:
8
+ SERVER_TOOL_USAGE_NAMES = {
9
+ 'code_interpreter' => 'code_execution', 'document_library' => 'file_search'
10
+ }.freeze
11
+
8
12
  module_function
9
13
 
10
14
  def completion_url
@@ -150,9 +154,15 @@ module RubyLLM
150
154
  input = usage['prompt_tokens'] && (usage['prompt_tokens'] + usage.fetch('connector_tokens', 0).to_i)
151
155
  {
152
156
  input_tokens: input,
153
- output_tokens: usage['completion_tokens'], server_tool_use: usage['connectors']
157
+ output_tokens: usage['completion_tokens'], server_tool_use: parse_connector_usage(usage['connectors'])
154
158
  }
155
159
  end
160
+
161
+ def parse_connector_usage(connectors)
162
+ connectors.to_h.transform_keys do |connector|
163
+ "#{SERVER_TOOL_USAGE_NAMES.fetch(connector, connector)}_requests"
164
+ end
165
+ end
156
166
  end
157
167
  end
158
168
  end