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
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Converse
6
+ # Describes Converse requests, and the token counting requests that
7
+ # wrap them, as RequestShape objects. A content block is a union keyed
8
+ # by its kind, and media names a format instead of a MIME type.
9
+ # Converse wants user and assistant messages to alternate, pairs tool
10
+ # results with uses by id, and takes reasoning back only with its
11
+ # signature.
12
+ module RequestShapes
13
+ MEDIA_KINDS = { 'image' => :image, 'audio' => :audio, 'video' => :video, 'document' => :document }.freeze
14
+
15
+ def parse_request_shape(payload)
16
+ request = shape_hash(shape_hash(payload['input'])['converse'])
17
+ request = payload if request.empty?
18
+ messages = request['messages']
19
+ return unless messages.is_a?(Array)
20
+
21
+ turns = messages.each_with_index.map { |message, index| shape_converse_turn(shape_hash(message), index) }
22
+ tools = shape_list(shape_hash(request['toolConfig'])['tools'])
23
+ shape_request(turns, payload:, rules: %i[alternating_roles signed_thinking],
24
+ instructions: shape_converse_blocks(request['system']),
25
+ tool_names: tools.map { |tool| shape_converse_tool_name(shape_hash(tool)) },
26
+ thinking_settings: shape_converse_thinking(request))
27
+ end
28
+
29
+ private
30
+
31
+ def shape_converse_turn(message, index)
32
+ parts = shape_converse_blocks(message['content'])
33
+ shape_turn(index, message['role'], message['role'] == 'assistant' ? :model : shape_answer(parts), parts)
34
+ end
35
+
36
+ def shape_converse_blocks(blocks)
37
+ shape_list(blocks).flat_map { |block| shape_converse_block(shape_hash(block)) }
38
+ end
39
+
40
+ def shape_converse_block(block)
41
+ type, value = block.first
42
+ return shape_text(value) if %w[text json].include?(type)
43
+
44
+ value = shape_hash(value)
45
+ case type
46
+ when 'toolUse' then shape_tool_call(value['name'], value['input'], id: value['toolUseId'])
47
+ when 'toolResult'
48
+ shape_tool_result(value['content'], id: value['toolUseId']) { |inner| shape_converse_block(inner) }
49
+ when 'reasoningContent' then shape_converse_reasoning(value)
50
+ when *MEDIA_KINDS.keys then shape_converse_media(MEDIA_KINDS[type], value)
51
+ else shape_other(type)
52
+ end
53
+ end
54
+
55
+ def shape_converse_reasoning(reasoning)
56
+ unless reasoning.key?('reasoningText')
57
+ return shape_text(nil, kind: :thinking, signed: shape_signature?(reasoning['redactedContent']))
58
+ end
59
+
60
+ text = shape_hash(reasoning['reasoningText'])
61
+ shape_text(text['text'], kind: :thinking, signed: shape_signature?(text['signature']))
62
+ end
63
+
64
+ def shape_converse_media(kind, media)
65
+ source = shape_hash(media['source'])
66
+ mime_type = shape_converse_mime_type(kind, media['format'])
67
+ return shape_inline(source['bytes'], kind:, mime_type:) if source.key?('bytes')
68
+ return shape_file(kind:, mime_type:) unless source.key?('text')
69
+
70
+ shape_media(kind, source: :inline, size: shape_length(source['text']), mime_type:)
71
+ end
72
+
73
+ # A format can name a container that holds either sound or video,
74
+ # such as mp4, so its MIME type counts only when it agrees.
75
+ def shape_converse_mime_type(kind, format)
76
+ return unless format.is_a?(String)
77
+
78
+ mime_type = RubyLLM::Files::MimeType.for(name: "file.#{format}")
79
+ mime_type if mime_type != 'application/octet-stream' && shape_media_kind(mime_type) == kind
80
+ end
81
+
82
+ def shape_converse_tool_name(tool)
83
+ shape_hash(tool['toolSpec'])['name'] || shape_hash(tool['systemTool'])['name']
84
+ end
85
+
86
+ # Each model family takes its reasoning settings in a field of its
87
+ # own, and some name an effort with a bare reasoning_config.
88
+ def shape_converse_thinking(request)
89
+ fields = shape_hash(request['additionalModelRequestFields'])
90
+ shape_settings(fields, 'reasoning_config', 'reasoning_effort')
91
+ .merge(shape_settings(fields['reasoning_config'], 'type', 'budget_tokens'))
92
+ .merge(shape_settings(fields['reasoningConfig'], 'type', 'maxReasoningEffort'))
93
+ .merge(shape_settings(fields['thinking'], 'type'))
94
+ .merge(shape_settings(fields['output_config'], 'effort'))
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -31,8 +31,7 @@ module RubyLLM
31
31
  on_failed_response: ->(chunk, env) { handle_failed_stream(chunk, env) }
32
32
  )
33
33
 
34
- response = @connection.post(stream_url, payload, usage: @usage_tracker) do |req|
35
- req.headers.merge!(@provider.sign_headers('POST', stream_url, body))
34
+ response = @connection.post(stream_url, body, usage: @usage_tracker, stream: true) do |req|
36
35
  req.headers.merge!(additional_headers) unless additional_headers.empty?
37
36
  req.headers['Accept'] = 'application/vnd.amazon.eventstream'
38
37
  (req.options.context ||= {})[RubyLLM::Transport::Connection::STREAM_PROGRESS_KEY] = progress
@@ -203,6 +202,7 @@ module RubyLLM
203
202
  output_tokens: extract_output_tokens(metadata_usage, usage),
204
203
  cache_read_tokens: extract_cache_read_tokens(metadata_usage, usage),
205
204
  cache_write_tokens: extract_cache_write_tokens(metadata_usage, usage),
205
+ cache_write_tokens_by_ttl: Chat.cache_write_by_ttl(metadata_usage) || Chat.cache_write_by_ttl(usage),
206
206
  thinking_tokens: extract_reasoning_tokens(metadata_usage, usage),
207
207
  finish_reason: extract_finish_reason(event)
208
208
  )
@@ -37,7 +37,8 @@ module RubyLLM
37
37
  else
38
38
  text = (block['reasoningText'] ||= { 'text' => '' })
39
39
  (delta['reasoningText'] || delta).slice('text', 'signature').each do |key, value|
40
- text[key] = text[key].to_s + value.to_s
40
+ text[key] = +text[key].to_s
41
+ text[key] << value.to_s
41
42
  end
42
43
  end
43
44
  end
@@ -4,10 +4,11 @@ module RubyLLM
4
4
  module Protocols
5
5
  # The AWS Bedrock Converse API. Requests are SigV4-signed by the provider.
6
6
  class Converse < Protocol
7
- REGION_PREFIXES = %w[global us-gov us eu apac ap sa ca me af il au jp].freeze
7
+ REGION_PREFIXES = %w[global us-gov us eu apac ap sa ca me af il au jp in].freeze
8
8
 
9
9
  include Converse::Chat
10
10
  include Converse::Media
11
+ include Converse::RequestShapes
11
12
  include Converse::Streaming
12
13
 
13
14
  # Nova 2 models execute built-in tools server-side when the request
@@ -42,10 +43,7 @@ module RubyLLM
42
43
  end
43
44
 
44
45
  def signed_post(url, payload, additional_headers = {})
45
- body = JSON.generate(payload)
46
-
47
- @connection.post(url, payload, usage: @usage_tracker) do |req|
48
- req.headers.merge!(@provider.sign_headers('POST', url, body))
46
+ @connection.post(url, JSON.generate(payload), usage: @usage_tracker) do |req|
49
47
  req.headers.merge!(additional_headers) unless additional_headers.empty?
50
48
  end
51
49
  end
@@ -5,21 +5,14 @@ module RubyLLM
5
5
  module DeepSeek
6
6
  # DeepSeek stores image files for reuse by vision models.
7
7
  class Files < Protocols::OpenAI::Files
8
- IMAGE_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
9
- MAX_FILE_SIZE = 64 * 1024 * 1024
10
-
11
8
  def download(_file_id)
12
9
  raise Error, 'DeepSeek does not support downloading uploaded files'
13
10
  end
14
11
 
15
12
  private
16
13
 
17
- def render_upload_payload(attachment, purpose: nil, **options)
18
- raise UnsupportedAttachmentError, attachment.mime_type unless IMAGE_TYPES.include?(attachment.mime_type)
19
- raise ArgumentError, 'DeepSeek image uploads cannot exceed 64 MiB' if file_size(attachment) > MAX_FILE_SIZE
20
- raise ArgumentError, 'DeepSeek file uploads require purpose: user_data' if purpose && purpose != 'user_data'
21
-
22
- super(attachment, purpose: 'user_data', **options)
14
+ def render_upload_payload(attachment, purpose: nil, **)
15
+ super(attachment, purpose: purpose || 'user_data', **)
23
16
  end
24
17
 
25
18
  def uploaded_file(data, **attributes)
@@ -5,8 +5,6 @@ module RubyLLM
5
5
  class ElevenLabs
6
6
  class Flows
7
7
  module Images # :nodoc: all
8
- MASK_MODELS = %w[gpt-image-1 gpt-image-1.5 gpt-image-2].freeze
9
-
10
8
  def images_url(**)
11
9
  'v1/flows/image'
12
10
  end
@@ -20,11 +18,7 @@ module RubyLLM
20
18
  payload = { model_id: model, prompt: }
21
19
  payload[:images] = images.map { |image| render_media_reference(image) } if images.any?
22
20
  payload[:aspect_ratio] = image_aspect_ratio(size) if size && size != 'auto'
23
- if mask
24
- raise ArgumentError, 'ElevenLabs image masks require a GPT Image model' unless MASK_MODELS.include?(model)
25
-
26
- payload[:mask] = render_media_reference(Attachment.wrap(mask, config: @config).first)
27
- end
21
+ payload[:mask] = render_media_reference(Attachment.wrap(mask, config: @config).first) if mask
28
22
  Support::Utils.deep_merge(payload, provider_options)
29
23
  end
30
24
 
@@ -5,10 +5,6 @@ module RubyLLM
5
5
  class ElevenLabs
6
6
  class Flows
7
7
  module Videos # :nodoc: all
8
- REFERENCE_VIDEO_MODELS = %w[
9
- bytedance-seedance-v2 bytedance-seedance-v2-fast bytedance-seedance-v2-mini bytedance-seedance-v2.5
10
- ].freeze
11
-
12
8
  def video_url
13
9
  'v1/flows/video'
14
10
  end
@@ -17,22 +13,20 @@ module RubyLLM
17
13
  payload = if model == 'creatify-aurora'
18
14
  render_character_animation(prompt, with:)
19
15
  else
20
- render_video_inputs(prompt, model:, with:)
16
+ render_video_inputs(prompt, with:)
21
17
  end
22
18
  Support::Utils.deep_merge({ model_id: model }.merge(payload), provider_options)
23
19
  end
24
20
 
25
- def render_video_inputs(prompt, model:, with:)
26
- raise ArgumentError, 'This ElevenLabs video model requires a prompt' if prompt.to_s.empty?
27
-
28
- payload = { prompt: }
21
+ def render_video_inputs(prompt, with:)
22
+ payload = { prompt: }.compact
29
23
  if with.all?(&:image?)
30
24
  raise ArgumentError, 'Video generation accepts at most two frame images' if with.size > 2
31
25
 
32
26
  payload[:start_frame] = render_media_reference(with[0]) if with[0]
33
27
  payload[:end_frame] = render_media_reference(with[1]) if with[1]
34
28
  else
35
- render_video_references(payload, with, model:)
29
+ render_video_references(payload, with)
36
30
  end
37
31
  payload
38
32
  end
@@ -51,19 +45,19 @@ module RubyLLM
51
45
  { image: render_media_reference(image.first), audio: render_media_reference(audio.first) }
52
46
  end
53
47
 
54
- def render_video_references(payload, attachments, model:)
55
- unless REFERENCE_VIDEO_MODELS.include?(model)
56
- raise ArgumentError, 'This ElevenLabs video model only accepts image attachments'
57
- end
58
-
48
+ def render_video_references(payload, attachments)
59
49
  { images: :image?, audios: :audio?, videos: :video? }.each do |field, predicate|
60
50
  references = attachments.select { |attachment| attachment.public_send(predicate) }
61
51
  payload[field] = references.map { |attachment| render_media_reference(attachment) } if references.any?
62
52
  end
63
53
  end
64
54
 
65
- def parse_video_job(response, model:)
66
- VideoJob.new(id: response.body.fetch('id'), protocol: self, model:, raw: response.body)
55
+ def parse_video_job(response, model:, **request)
56
+ VideoJob.new(id: response.body.fetch('id'), protocol: self, model:, raw: response.body, **request)
57
+ end
58
+
59
+ def video_request_settings(request)
60
+ { duration: request[:duration_secs], resolution: request[:resolution] }
67
61
  end
68
62
 
69
63
  def video_job_url(job)
@@ -4,8 +4,6 @@ module RubyLLM
4
4
  module Protocols
5
5
  class ElevenLabs
6
6
  module StreamingTranscription # :nodoc: all
7
- SAMPLE_RATES = [8000, 16_000, 22_050, 24_000, 44_100, 48_000].freeze
8
-
9
7
  def stream_transcription(payload, model:, &block)
10
8
  validate_streaming_transcription(payload)
11
9
  audio = RubyLLM::Transcription::WavAudio.new(payload.fetch(:file).io.read)
@@ -74,8 +72,7 @@ module RubyLLM
74
72
  end
75
73
 
76
74
  def pcm_audio?(audio)
77
- [audio.channels, audio.encoding, audio.bits_per_sample] == [1, 1, 16] &&
78
- SAMPLE_RATES.include?(audio.sample_rate)
75
+ [audio.channels, audio.encoding, audio.bits_per_sample] == [1, 1, 16]
79
76
  end
80
77
 
81
78
  def streaming_transcription_url(payload, audio_format:)
@@ -15,7 +15,6 @@ module RubyLLM
15
15
 
16
16
  GEMINI_INLINE_FILE_THRESHOLD = 20 * 1024 * 1024
17
17
  VERTEX_INLINE_FILE_THRESHOLD = 7 * 1024 * 1024
18
- GEMINI_FILE_UPLOAD_LIMIT = 2 * 1024 * 1024 * 1024
19
18
 
20
19
  module_function
21
20
 
@@ -84,8 +83,8 @@ module RubyLLM
84
83
  "models/#{@model.id}:countTokens"
85
84
  end
86
85
 
87
- def render_count_tokens_payload(messages, model:, **options)
88
- request = count_tokens_request(messages, model: model, **options)
86
+ def render_count_tokens_payload(messages, model:, **)
87
+ request = count_tokens_request(messages, model: model, **)
89
88
  { generateContentRequest: request.merge(model: "models/#{model.id}") }
90
89
  end
91
90
 
@@ -128,10 +127,6 @@ module RubyLLM
128
127
  @provider.slug == 'vertexai' ? VERTEX_INLINE_FILE_THRESHOLD : GEMINI_INLINE_FILE_THRESHOLD
129
128
  end
130
129
 
131
- def provider_file_upload_limit
132
- GEMINI_FILE_UPLOAD_LIMIT
133
- end
134
-
135
130
  def provider_file_attachable?(attachment)
136
131
  attachment.image? || attachment.video? || attachment.audio? || attachment.pdf? || attachment.text?
137
132
  end
@@ -160,7 +155,7 @@ module RubyLLM
160
155
  end
161
156
 
162
157
  def format_parts(msg)
163
- if msg.role == :assistant && msg.raw_content
158
+ if msg.role == :assistant && msg.raw_content.is_a?(Array)
164
159
  msg.raw_content
165
160
  elsif msg.tool_call?
166
161
  format_tool_call(msg)
@@ -172,19 +167,20 @@ module RubyLLM
172
167
  end
173
168
 
174
169
  def format_message_parts(msg)
175
- parts = []
170
+ parts = Media.format_content(msg.content, msg.attachments)
171
+ thinking = msg.thinking if msg.role == :assistant
172
+ return parts unless thinking
176
173
 
177
- parts << build_thought_part(msg.thinking) if msg.role == :assistant && msg.thinking
178
-
179
- parts.concat(Media.format_content(msg.content, msg.attachments))
180
- parts
174
+ signature = own_signature(msg)
175
+ parts = sign_last_part(parts, signature) if signature
176
+ thinking.text ? [{ thought: true, text: thinking.text }, *parts] : parts
181
177
  end
182
178
 
183
- def build_thought_part(thinking)
184
- part = { thought: true }
185
- part[:text] = thinking.text if thinking.text
186
- part[:thoughtSignature] = thinking.signature if thinking.signature
187
- part
179
+ # Gemini signs the last part of an answer. An answer without parts gets
180
+ # the empty text part a stream ends with: Gemini refuses a part without data.
181
+ def sign_last_part(parts, signature)
182
+ parts = [Media.format_text('')] if parts.empty?
183
+ [*parts[...-1], parts.last.merge(thoughtSignature: signature)]
188
184
  end
189
185
 
190
186
  def parse_completion_body(data, raw:)
@@ -203,11 +199,12 @@ module RubyLLM
203
199
  ),
204
200
  tool_calls: tool_calls,
205
201
  server_tool_calls: extract_server_tool_calls(data, parts),
206
- raw_content: parts.any? { |part| server_tool_part?(part) } ? parts : nil,
202
+ raw_content: parts.any? { |part| server_tool_part?(part) } ? without_search_suggestions(parts) : nil,
207
203
  input_tokens: input_tokens(data),
208
204
  output_tokens: calculate_output_tokens(data),
209
205
  cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
210
206
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
207
+ server_tool_use: parse_server_tool_use(data),
211
208
  finish_reason: normalize_finish_reason(
212
209
  data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
213
210
  ),
@@ -216,6 +213,14 @@ module RubyLLM
216
213
  )
217
214
  end
218
215
 
216
+ # Gemini 3 bills each distinct, non-empty query a grounding ran.
217
+ def parse_server_tool_use(data)
218
+ searches = Array(data['candidates']).sum do |candidate|
219
+ Array(candidate.dig('groundingMetadata', 'webSearchQueries')).reject(&:empty?).uniq.size
220
+ end
221
+ { 'web_search_requests' => searches }
222
+ end
223
+
219
224
  def input_tokens(data)
220
225
  prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
221
226
  return unless prompt_tokens
@@ -243,6 +248,18 @@ module RubyLLM
243
248
  part.key?('executableCode') || part.key?('codeExecutionResult')
244
249
  end
245
250
 
251
+ # Google's terms forbid storing search suggestions, and Gemini takes
252
+ # a replayed search result without them.
253
+ def without_search_suggestions(parts)
254
+ parts.map do |part|
255
+ tool_response = part['toolResponse']
256
+ response = tool_response&.dig('response')
257
+ next part unless response.is_a?(Hash) && response.key?('search_suggestions')
258
+
259
+ part.merge('toolResponse' => tool_response.merge('response' => response.except('search_suggestions')))
260
+ end
261
+ end
262
+
246
263
  def extract_server_tool_calls(data, parts)
247
264
  calls = parts.select { |part| server_tool_part?(part) }.map do |part|
248
265
  ServerToolCall.new(
@@ -263,7 +280,9 @@ module RubyLLM
263
280
  queries = candidate.dig('groundingMetadata', 'webSearchQueries')
264
281
  if queries&.any?
265
282
  calls << ServerToolCall.new(type: 'google_search', input: { 'queries' => queries },
266
- raw: { 'webSearchQueries' => queries })
283
+ raw: { 'webSearchQueries' => queries },
284
+ search_suggestions: candidate.dig('groundingMetadata', 'searchEntryPoint',
285
+ 'renderedContent'))
267
286
  end
268
287
 
269
288
  url_metadata = candidate['urlContextMetadata']
@@ -370,6 +389,7 @@ module RubyLLM
370
389
  @messages = messages
371
390
  @index = 0
372
391
  @tool_call_names = {}
392
+ @turn_start = messages.rindex { |message| message.role == :user }
373
393
  end
374
394
 
375
395
  def format
@@ -437,12 +457,21 @@ module RubyLLM
437
457
  end
438
458
 
439
459
  def build_standard_message(message)
460
+ parts = @provider.send(:format_parts, message)
461
+ parts = @provider.send(:sign_step, parts) if message.tool_call? && current_turn?
462
+
440
463
  {
441
464
  role: @provider.send(:format_role, message.role),
442
- parts: @provider.send(:format_parts, message)
465
+ parts: parts
443
466
  }
444
467
  end
445
468
 
469
+ # Gemini checks signatures only in the current turn, which starts at
470
+ # the last user message.
471
+ def current_turn?
472
+ @turn_start.nil? || @index > @turn_start
473
+ end
474
+
446
475
  def format_tool_result(message, tool_name)
447
476
  @provider.send(:format_tool_result, message, tool_name)
448
477
  end
@@ -30,8 +30,8 @@ module RubyLLM
30
30
  true
31
31
  end
32
32
 
33
- def render_embedding(text, dimensions: nil, **options)
34
- payload = render_embedding_payload(text, dimensions:, **options)
33
+ def render_embedding(text, dimensions: nil, **)
34
+ payload = render_embedding_payload(text, dimensions:, **)
35
35
  return payload if text.is_a?(Array) || !payload.key?(:requests)
36
36
 
37
37
  payload.fetch(:requests).first
@@ -102,7 +102,7 @@ module RubyLLM
102
102
  def gemini_connection(request_json:, response_json:)
103
103
  connection = Transport::Connection.basic(@config) do |connection|
104
104
  connection.request :json if request_json
105
- connection.response :json if response_json
105
+ connection.use Transport::JsonResponse if response_json
106
106
  connection.adapter @config.faraday_adapter
107
107
  connection.use :llm_errors, provider: @provider
108
108
  end
@@ -125,7 +125,11 @@ module RubyLLM
125
125
 
126
126
  def parse_gemini_image_responses(data, model:)
127
127
  parts = gemini_image_parts(data)
128
- raise Error, 'Unexpected response format from Gemini image generation API' if parts.empty?
128
+ if parts.empty?
129
+ usage = gemini_image_usage(data)
130
+ @usage_tracker&.observe_tokens(Tokens.new(input: usage['input_tokens'], output: usage['output_tokens']))
131
+ raise Error, 'Unexpected response format from Gemini image generation API'
132
+ end
129
133
 
130
134
  parts.map.with_index do |image_data, index|
131
135
  Image.new(
@@ -10,23 +10,27 @@ module RubyLLM
10
10
  module Media
11
11
  module_function
12
12
 
13
+ def supported_attachment?(attachment)
14
+ %i[image audio video pdf text].include?(attachment.type)
15
+ end
16
+
13
17
  def format_content(content, attachments = [])
14
18
  parts = []
15
19
  parts << format_text(content) if content
16
20
 
17
21
  attachments.each do |attachment|
18
- parts << format_content_attachment(attachment)
22
+ parts << with_media_resolution(format_content_attachment(attachment), attachment)
19
23
  end
20
24
 
21
25
  parts
22
26
  end
23
27
 
24
28
  def format_content_attachment(attachment)
29
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
30
+
25
31
  case attachment.type
26
32
  when :text
27
33
  format_text_file(attachment)
28
- when :document, :unknown
29
- raise UnsupportedAttachmentError, attachment.mime_type
30
34
  else
31
35
  format_attachment(attachment)
32
36
  end
@@ -43,6 +47,14 @@ module RubyLLM
43
47
  }
44
48
  end
45
49
 
50
+ def with_media_resolution(part, attachment)
51
+ return part unless attachment.resolution && %i[image video pdf].include?(attachment.type)
52
+
53
+ level = attachment.resolution == :original ? :ultra_high : attachment.resolution
54
+ level = :high if level == :ultra_high && attachment.type != :image
55
+ part.merge(media_resolution: { level: "MEDIA_RESOLUTION_#{level.upcase}" })
56
+ end
57
+
46
58
  def format_file_data(attachment)
47
59
  uri = attachment.provider_file_uri || attachment.provider_file_id
48
60
  {
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Gemini
6
+ # Describes generateContent requests, and the countTokens requests that
7
+ # wrap them, as RequestShape objects. RubyLLM writes media parts in
8
+ # snake case, and a model's own parts replay in camel case. Gemini pairs
9
+ # function responses with calls by position and name, wants data in
10
+ # every part, and from Gemini 3 needs a signature on the first call of
11
+ # each step in the current turn.
12
+ module RequestShapes
13
+ PART_MODIFIERS = %w[thought thoughtSignature thought_signature mediaResolution media_resolution
14
+ videoMetadata video_metadata partMetadata part_metadata].freeze
15
+ DATA_FIELDS = %w[text inlineData inline_data fileData file_data functionCall function_call functionResponse
16
+ function_response executableCode executable_code codeExecutionResult code_execution_result
17
+ toolCall tool_call toolResponse tool_response].freeze
18
+ FUNCTION_DECLARATIONS = %w[functionDeclarations function_declarations].freeze
19
+ THINKING_SETTINGS = %w[includeThoughts include_thoughts thinkingBudget thinking_budget thinkingLevel
20
+ thinking_level].freeze
21
+
22
+ def parse_request_shape(payload)
23
+ request = shape_hash(payload['generateContentRequest'])
24
+ request = payload if request.empty?
25
+ contents = request['contents']
26
+ return unless contents.is_a?(Array)
27
+
28
+ turns = contents.each_with_index.map { |content, index| shape_gemini_turn(shape_hash(content), index) }
29
+ shape_request(turns, payload:, pairing: :position, rules: %i[signed_steps],
30
+ instructions: shape_gemini_parts(request['systemInstruction'] ||
31
+ request['system_instruction']),
32
+ tool_names: shape_list(request['tools']).flat_map { |tool| shape_gemini_tools(tool) },
33
+ thinking_settings: shape_gemini_thinking(request))
34
+ end
35
+
36
+ private
37
+
38
+ def shape_gemini_turn(content, index)
39
+ parts = shape_gemini_parts(content)
40
+ shape_turn(index, content['role'], content['role'] == 'model' ? :model : shape_answer(parts), parts)
41
+ end
42
+
43
+ def shape_gemini_parts(content)
44
+ shape_list(shape_hash(content)['parts']).flat_map { |part| shape_gemini_part(shape_hash(part)) }
45
+ end
46
+
47
+ def shape_gemini_part(part)
48
+ signed = shape_signature?(part['thoughtSignature'] || part['thought_signature'])
49
+ call = shape_gemini_field(part, 'functionCall', 'function_call')
50
+ response = shape_gemini_field(part, 'functionResponse', 'function_response')
51
+
52
+ if call
53
+ shape_tool_call(call['name'], call['args'], id: call['id'], signed:)
54
+ elsif response
55
+ shape_gemini_tool_result(response)
56
+ elsif part.key?('text')
57
+ shape_text(part['text'], kind: part['thought'] ? :thinking : :text, signed:)
58
+ else
59
+ shape_gemini_data(part, signed)
60
+ end
61
+ end
62
+
63
+ # RubyLLM wraps a result's text in content parts, and Gemini 3 sends
64
+ # a result's media in parts beside it.
65
+ def shape_gemini_tool_result(response)
66
+ result = response['response']
67
+ content = shape_hash(result)['content']
68
+ content = result unless content.is_a?(Array)
69
+ shape_tool_result(content, name: response['name']) { |part| shape_gemini_part(part) } +
70
+ shape_gemini_parts(response)
71
+ end
72
+
73
+ # A thought or a signature alone is a part with no data, which
74
+ # Gemini refuses.
75
+ def shape_gemini_data(part, signed)
76
+ if (data = shape_gemini_field(part, 'inlineData', 'inline_data'))
77
+ return shape_inline(data['data'], mime_type: data['mimeType'] || data['mime_type'], signed:)
78
+ end
79
+ if (file = shape_gemini_field(part, 'fileData', 'file_data'))
80
+ return shape_file(mime_type: file['mimeType'] || file['mime_type'])
81
+ end
82
+
83
+ spec = if part['thought']
84
+ shape_text(nil, kind: :thinking, signed:)
85
+ else
86
+ shape_other(part.keys.find { |key| !PART_MODIFIERS.include?(key) }, signed:)
87
+ end
88
+ spec.without_data = !part.keys.intersect?(DATA_FIELDS)
89
+ spec
90
+ end
91
+
92
+ def shape_gemini_field(part, *keys)
93
+ keys.map { |key| part[key] }.find { |value| value.is_a?(Hash) }
94
+ end
95
+
96
+ def shape_gemini_tools(tool)
97
+ shape_hash(tool).flat_map do |key, value|
98
+ next [key] unless FUNCTION_DECLARATIONS.include?(key)
99
+
100
+ shape_list(value).map { |declaration| shape_hash(declaration)['name'] }
101
+ end
102
+ end
103
+
104
+ def shape_gemini_thinking(request)
105
+ config = shape_hash(request['generationConfig'] || request['generation_config'])
106
+ shape_settings(config['thinkingConfig'] || config['thinking_config'], *THINKING_SETTINGS)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -28,6 +28,7 @@ module RubyLLM
28
28
  output_tokens: extract_output_tokens(data),
29
29
  cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
30
30
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
31
+ server_tool_use: parse_server_tool_use(data),
31
32
  finish_reason: normalize_finish_reason(
32
33
  data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
33
34
  ),
@@ -63,7 +64,7 @@ module RubyLLM
63
64
 
64
65
  {
65
66
  server_tool_calls: calls,
66
- raw_content: @saw_server_part ? @stream_parts : nil
67
+ raw_content: @saw_server_part ? without_search_suggestions(@stream_parts) : nil
67
68
  }
68
69
  end
69
70