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
@@ -24,11 +24,6 @@ module RubyLLM
24
24
 
25
25
  def render_batch_request(request, type:)
26
26
  body = JSON.parse(JSON.generate(batch_payload(request, except: :model)))
27
- if type == 'batch-embed-v2-input' && body['output_dimension']
28
- raise ArgumentError,
29
- 'Cohere batch datasets currently reject dimensions; omit dimensions to use the model default'
30
- end
31
-
32
27
  render_batch_chat(body) if type == 'batch-chat-v2-input'
33
28
  allowed = type == 'batch-chat-v2-input' ? CHAT_FIELDS : EMBEDDING_FIELDS
34
29
  unsupported = body.keys - allowed
@@ -40,13 +40,7 @@ module RubyLLM
40
40
  file = datasets.wait_for_validation(data.fetch('output_dataset_id'))
41
41
  model = RubyLLM.models.find(data.fetch('model'), provider: @provider.slug, config: @config)
42
42
  parser = self.class.new(@provider, model)
43
- results = datasets.records(file).map { |row| parse_batch_result(row, parser:, model: model.id) }
44
- unless results.map(&:first).uniq.size == results.size
45
- raise Error,
46
- 'Cohere returned duplicate batch request IDs'
47
- end
48
-
49
- results
43
+ datasets.records(file).map { |row| parse_batch_result(row, parser:, model: model.id) }
50
44
  end
51
45
 
52
46
  private
@@ -84,6 +78,8 @@ module RubyLLM
84
78
  return [index, nil, batch_failure(custom_id, row['error'])] if !row['error'].to_s.empty? || !row['body']
85
79
 
86
80
  body = row.fetch('body')
81
+ return [index, nil, batch_failure(custom_id, 'generation failed')] if body['finish_reason'] == 'ERROR'
82
+
87
83
  result = if body['embeddings']
88
84
  parser.send(:parse_embedding_response, Response.new(body), model:,
89
85
  text: shape == 'array' ? [] : nil)
@@ -107,6 +107,8 @@ module RubyLLM
107
107
  end
108
108
 
109
109
  def parse_completion_body(data, raw:)
110
+ raise_failed_generation(nil, response: raw) if data['finish_reason'] == 'ERROR'
111
+
110
112
  message_data = data['message'] || {}
111
113
  blocks = Array(message_data['content'])
112
114
  content, offsets = extract_text(blocks)
@@ -125,6 +127,12 @@ module RubyLLM
125
127
  )
126
128
  end
127
129
 
130
+ # Cohere answers a generation that failed on its side with an ERROR
131
+ # finish reason instead of an error status.
132
+ def raise_failed_generation(message, response: nil)
133
+ raise ServerError.new(message || 'The generation failed due to an internal error', response:)
134
+ end
135
+
128
136
  # Cohere reports what the model processed under tokens and what it
129
137
  # charges for under billed_units; the two differ because Cohere does
130
138
  # not bill its own preamble.
@@ -37,11 +37,14 @@ module RubyLLM
37
37
  end
38
38
 
39
39
  def image_embedding_inputs(text, attachments) # :nodoc:
40
- raise ArgumentError, 'Cohere Embed v3 accepts text or an image, not both' unless text.nil? || text == ''
41
- raise ArgumentError, 'Cohere Embed v3 accepts one image per request' unless attachments.one?
42
- raise UnsupportedAttachmentError, attachments.first.mime_type unless attachments.first.image?
40
+ raise ArgumentError, 'Cohere Embed v3 accepts text or images, not both' unless text.nil? || text == ''
43
41
 
44
- { images: ["data:#{attachments.first.mime_type};base64,#{attachments.first.encoded}"] }
42
+ images = attachments.map do |attachment|
43
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
44
+
45
+ "data:#{attachment.mime_type};base64,#{attachment.encoded}"
46
+ end
47
+ { images: }
45
48
  end
46
49
 
47
50
  def parse_embedding_response(response, model:, text:)
@@ -11,6 +11,10 @@ module RubyLLM
11
11
 
12
12
  module_function
13
13
 
14
+ def supported_attachment?(attachment)
15
+ %i[image text].include?(attachment.type)
16
+ end
17
+
14
18
  # Cohere user messages accept only text and image_url blocks. Text
15
19
  # attachments become citable documents when citations are on, so they
16
20
  # are pulled out of the message and left out of the content blocks.
@@ -19,13 +23,13 @@ module RubyLLM
19
23
  parts << format_text(content) if content
20
24
 
21
25
  attachments.each do |attachment|
26
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
27
+
22
28
  case attachment.type
23
29
  when :image
24
30
  parts << format_image(attachment)
25
31
  when :text
26
32
  parts << format_text(attachment.for_llm) unless citations
27
- else
28
- raise UnsupportedAttachmentError, attachment.mime_type
29
33
  end
30
34
  end
31
35
 
@@ -16,7 +16,7 @@ module RubyLLM
16
16
  raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
17
17
  raise ArgumentError, 'Cohere Parse accepts one image; pages must be [0]' unless pages.nil? || pages == [0]
18
18
 
19
- reference = attachment.url? ? attachment.source.to_s : attachment.for_llm
19
+ reference = attachment.url_or_data_uri
20
20
  {
21
21
  model: model,
22
22
  document: { type: 'image_url', image_url: reference },
@@ -13,6 +13,7 @@ module RubyLLM
13
13
 
14
14
  def build_chunk(data)
15
15
  type = data['type']
16
+ raise_failed_generation(data.dig('delta', 'error')) if data.dig('delta', 'finish_reason') == 'ERROR'
16
17
  track_content_lengths(data, type)
17
18
 
18
19
  Chunk.new(
@@ -76,6 +76,10 @@ module RubyLLM
76
76
  }
77
77
  end
78
78
 
79
+ def supported_message_attachment?(message, attachment)
80
+ message.role == :tool ? attachment.text? : supported_attachment?(attachment)
81
+ end
82
+
79
83
  # Search results become document content blocks, the shape Cohere
80
84
  # cites tool output against. A tool that attaches files sends its
81
85
  # text and each attachment as blocks, so the model reads what the
@@ -86,7 +90,7 @@ module RubyLLM
86
90
  return msg.content.to_s if msg.attachments.empty?
87
91
 
88
92
  [Media.format_text(msg.content.to_s)] + msg.attachments.map do |attachment|
89
- raise UnsupportedAttachmentError, attachment.mime_type unless attachment.type == :text
93
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_message_attachment?(msg, attachment)
90
94
 
91
95
  Media.format_text(attachment.for_llm)
92
96
  end
@@ -16,6 +16,7 @@ module RubyLLM
16
16
  include Cohere::Streaming
17
17
  include Cohere::Tools
18
18
  include Cohere::Transcription
19
+ include ChatCompletions::RequestShapes
19
20
  end
20
21
  end
21
22
  end
@@ -16,8 +16,6 @@ module RubyLLM
16
16
  BEDROCK_INLINE_DOCUMENT_LIMIT = 4_500_000
17
17
  PROMPT_CACHE_OPTIONS = %i[ttl].freeze
18
18
  COUNT_TOKENS_KEYS = %i[messages system toolConfig additionalModelRequestFields].freeze
19
- RANGED_EFFORTS = %w[low medium high].freeze
20
- MINIMUM_BUDGET_TOKENS = 1
21
19
 
22
20
  module_function
23
21
 
@@ -124,6 +122,7 @@ module RubyLLM
124
122
  output_tokens: usage['outputTokens'],
125
123
  cache_read_tokens: usage['cacheReadInputTokens'],
126
124
  cache_write_tokens: usage['cacheWriteInputTokens'],
125
+ cache_write_tokens_by_ttl: cache_write_by_ttl(usage),
127
126
  thinking_tokens: reasoning_tokens(usage),
128
127
  finish_reason: normalize_finish_reason(data['stopReason']),
129
128
  model: data['modelId'] || @model&.id,
@@ -143,6 +142,15 @@ module RubyLLM
143
142
  usage['reasoningTokens'] || usage.dig('outputTokensDetails', 'reasoningTokens')
144
143
  end
145
144
 
145
+ def cache_write_by_ttl(usage)
146
+ details = usage['cacheDetails']
147
+ return unless details.is_a?(Array)
148
+
149
+ details.each_with_object(Hash.new(0)) do |detail, counts|
150
+ counts[detail['ttl']] += detail['inputTokens'].to_i if detail['ttl']
151
+ end
152
+ end
153
+
146
154
  def format_messages(messages, caching: nil, automatic_cache_target: nil, citations: false)
147
155
  rendered = []
148
156
  tool_result_blocks = []
@@ -190,7 +198,9 @@ module RubyLLM
190
198
  }
191
199
  end
192
200
  end
193
- blocks << converse_cache_block_for(caching) if cache_boundary?(msg, automatic_cache_target, caching:)
201
+ if cache_boundary?(msg, automatic_cache_target, caching:)
202
+ blocks << converse_cache_block_for(caching, ttl: msg.cache_ttl)
203
+ end
194
204
 
195
205
  blocks
196
206
  end
@@ -205,9 +215,13 @@ module RubyLLM
205
215
  blocks
206
216
  end
207
217
 
218
+ # Reasoning rebuilt from thinking text goes back only to the provider
219
+ # that produced it: Claude refuses another provider's signature, and
220
+ # a block without one.
208
221
  def format_thinking_blocks(msg)
209
222
  blocks = msg.raw_reasoning['converse'] if msg.raw_reasoning.is_a?(Hash)
210
223
  return Support::Utils.deep_dup(blocks) if blocks
224
+ return [] unless msg.thinking && producer_slug(msg) == @provider.slug
211
225
 
212
226
  [format_thinking_block(msg.thinking)].compact
213
227
  end
@@ -257,7 +271,7 @@ module RubyLLM
257
271
  messages.flat_map do |msg|
258
272
  blocks = Media.format_content(msg.content, msg.attachments, used_document_names: @used_document_names)
259
273
  if cache_boundary?(msg, automatic_cache_target, caching:)
260
- blocks + [converse_cache_block_for(caching)]
274
+ blocks + [converse_cache_block_for(caching, ttl: msg.cache_ttl)]
261
275
  else
262
276
  blocks
263
277
  end
@@ -278,10 +292,11 @@ module RubyLLM
278
292
  caching != false && (message.cache_until_here? || message.equal?(automatic_cache_target))
279
293
  end
280
294
 
281
- def converse_cache_block_for(caching)
295
+ def converse_cache_block_for(caching, ttl: nil)
282
296
  options = prompt_cache_options(caching)
297
+ ttl ||= options[:ttl]
283
298
  point = { type: 'default' }
284
- point[:ttl] = options[:ttl] if options[:ttl]
299
+ point[:ttl] = ttl if ttl
285
300
  { cachePoint: point }
286
301
  end
287
302
 
@@ -356,7 +371,7 @@ module RubyLLM
356
371
  def format_additional_model_request_fields(thinking, model, max_output_tokens = nil)
357
372
  fields = {}
358
373
 
359
- reasoning_fields = format_reasoning_fields(thinking, model, max_output_tokens)
374
+ reasoning_fields = Reasoning.format_reasoning_fields(thinking, model, max_output_tokens)
360
375
  fields = RubyLLM::Support::Utils.deep_merge(fields, reasoning_fields) if reasoning_fields
361
376
 
362
377
  fields.empty? ? nil : fields
@@ -382,136 +397,12 @@ module RubyLLM
382
397
  }
383
398
  end
384
399
 
385
- NOVA_DEFAULT_REASONING_EFFORT = 'low'
386
-
387
- def format_reasoning_fields(thinking, model, max_output_tokens = nil)
388
- return nil unless thinking&.enabled?
389
- return format_nova_reasoning_fields(thinking, model) if nova_model?(model)
390
- return { reasoning_config: { type: 'disabled' } } if thinking.enabled == false
391
-
392
- effort = thinking.effort.to_s
393
- budget = reasoning_budget(thinking, effort, model, max_output_tokens)
394
- return { reasoning_config: { type: 'enabled', budget_tokens: budget } } if budget
395
- return nil if effort.empty? || effort == 'none'
396
-
397
- { reasoning_effort: effort }
398
- end
399
-
400
- def nova_model?(model)
401
- foundation_model_id(model&.id).start_with?('amazon.nova')
402
- end
403
-
404
- # Nova 2 takes reasoningConfig with an effort level instead of a token budget,
405
- # and rejects an enabled reasoningConfig that names no effort.
406
- def format_nova_reasoning_fields(thinking, model)
407
- if thinking.budget
408
- raise ArgumentError,
409
- "#{model&.id} takes a reasoning effort, not a token budget of #{thinking.budget}. " \
410
- 'Pass with_thinking(effort:) instead.'
411
- end
412
-
413
- return { reasoningConfig: { type: 'disabled' } } if thinking.enabled == false
414
-
415
- effort = thinking.effort.to_s
416
- return nil if effort == 'none'
417
-
418
- effort = NOVA_DEFAULT_REASONING_EFFORT if effort.empty?
419
- { reasoningConfig: { type: 'enabled', maxReasoningEffort: effort } }
420
- end
421
-
422
- def reasoning_budget(thinking, effort, model, max_output_tokens)
423
- return thinking.budget if thinking.budget.is_a?(Integer)
424
- return nil if effort.empty? || effort == 'none'
425
-
426
- schema = reasoning_budget_schema(model)
427
- schema && effort_budget_tokens(effort, schema, model, max_output_tokens)
428
- end
429
-
430
- # Bedrock only publishes Converse metadata for some regional entries, so use the
431
- # schema from another entry for the same foundation model when needed.
432
- def reasoning_budget_schema(model)
433
- schema = budget_tokens_schema(model)
434
- return schema if schema
435
- return unless model
436
-
437
- foundation_id = foundation_model_id(model.id)
438
- RubyLLM.models.all.each do |candidate|
439
- next unless candidate.provider == 'bedrock' && candidate.id != model.id
440
- next unless foundation_model_id(candidate.id) == foundation_id
441
-
442
- return schema if (schema = budget_tokens_schema(candidate))
443
- end
444
-
445
- nil
446
- end
447
-
448
- def budget_tokens_schema(model)
449
- metadata = RubyLLM::Support::Utils.deep_symbolize_keys(model&.metadata || {})
450
- raw_schema = metadata.dig(:converse, :additionalRequestFieldsSchema)
451
- return unless raw_schema.is_a?(String)
452
-
453
- schema = JSON.parse(raw_schema, symbolize_names: true)
454
- budget = schema.is_a?(Hash) ? schema.dig(:reasoningConfig, :budgetTokens) : nil
455
- budget if budget.is_a?(Hash)
456
- rescue JSON::ParserError
457
- nil
458
- end
459
-
460
400
  # Inference profile and foundation model ARNs name the model in their last segment.
461
401
  def foundation_model_id(model_id)
462
402
  prefixes = Converse::REGION_PREFIXES.join('|')
463
403
  model_id.to_s.rpartition('/').last.sub(/\A(?:#{prefixes})\./, '')
464
404
  end
465
405
 
466
- # Models that take a budget reject reasoning_effort, so effort has to become a budget.
467
- # Bedrock names the levels of an enumerated budget after the efforts they stand for;
468
- # otherwise the effort spans the range the schema allows.
469
- def effort_budget_tokens(effort, schema, model, max_output_tokens)
470
- budget = enumerated_budget(effort, schema) || ranged_budget(effort, schema)
471
- return nil unless budget
472
-
473
- minimum = schema[:minimum].is_a?(Integer) ? schema[:minimum] : MINIMUM_BUDGET_TOKENS
474
- return [budget, minimum].max unless max_output_tokens
475
-
476
- budget.clamp(minimum, budget_ceiling(model, minimum, max_output_tokens))
477
- end
478
-
479
- # Bedrock rejects a budget that leaves no room for the answer.
480
- def budget_ceiling(model, minimum, max_output_tokens)
481
- ceiling = max_output_tokens - 1
482
- return ceiling if minimum <= ceiling
483
-
484
- raise ArgumentError, "#{model&.id} reasons on a budget of at least #{minimum} tokens, and " \
485
- "max_output_tokens: #{max_output_tokens} leaves room for #{ceiling}. " \
486
- "Raise max_output_tokens above #{minimum} or turn thinking off."
487
- end
488
-
489
- def enumerated_budget(effort, schema)
490
- levels = schema[:enum]
491
- level = levels.is_a?(Hash) ? levels[effort.to_sym] : nil
492
- level if level.is_a?(Integer)
493
- end
494
-
495
- def ranged_budget(effort, schema)
496
- minimum = schema[:minimum]
497
- maximum = schema[:maximum]
498
- return nil unless minimum.is_a?(Integer) && maximum.is_a?(Integer)
499
-
500
- case effort
501
- when 'low' then minimum
502
- when 'medium' then minimum + ((maximum - minimum) / 2)
503
- when 'high' then maximum
504
- else raise ArgumentError, unknown_effort_message(effort, schema)
505
- end
506
- end
507
-
508
- def unknown_effort_message(effort, schema)
509
- levels = schema[:enum].is_a?(Hash) ? schema[:enum].keys.map(&:to_s) : []
510
- levels |= RANGED_EFFORTS
511
- "Bedrock has no reasoning budget for effort #{effort.inspect}. " \
512
- "Use #{levels.join(', ')}, or pass an explicit budget."
513
- end
514
-
515
406
  def format_thinking_block(thinking)
516
407
  return nil unless thinking
517
408
 
@@ -7,6 +7,20 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment)
11
+ return supported_document_format?(attachment) if attachment.provider_file?
12
+
13
+ case attachment.type
14
+ when :image, :text then true
15
+ when :pdf, :document then supported_document_format?(attachment)
16
+ when :audio
17
+ SUPPORTED_AUDIO_FORMATS.include?(AUDIO_FORMAT_ALIASES.fetch(attachment.format, attachment.format))
18
+ when :video
19
+ SUPPORTED_VIDEO_FORMATS.include?(VIDEO_FORMAT_ALIASES.fetch(attachment.format, attachment.format))
20
+ else false
21
+ end
22
+ end
23
+
10
24
  def format_content(content, attachments = [], used_document_names: nil, citations: false)
11
25
  used_document_names ||= {}
12
26
  blocks = []
@@ -18,6 +32,8 @@ module RubyLLM
18
32
  end
19
33
 
20
34
  def format_attachment(attachment, used_document_names:, citations: false)
35
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
36
+
21
37
  if attachment.provider_file?
22
38
  return format_provider_file_attachment(attachment, used_document_names:, citations:)
23
39
  end
@@ -37,8 +53,6 @@ module RubyLLM
37
53
  else
38
54
  format_text_attachment(attachment)
39
55
  end
40
- else
41
- raise UnsupportedAttachmentError, attachment.mime_type
42
56
  end
43
57
  end
44
58
 
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ class Converse
8
+ # Reasoning request fields for Bedrock Converse, whose shape depends on
9
+ # the model family.
10
+ module Reasoning
11
+ RANGED_EFFORTS = %w[low medium high].freeze
12
+ MINIMUM_BUDGET_TOKENS = 1
13
+ NOVA_DEFAULT_REASONING_EFFORT = 'low'
14
+
15
+ module_function
16
+
17
+ def format_reasoning_fields(thinking, model, max_output_tokens = nil)
18
+ return nil unless thinking&.enabled?
19
+ return format_nova_reasoning_fields(thinking, model) if nova_model?(model)
20
+ return disabled_reasoning_fields(model) if thinking.enabled == false
21
+ return format_adaptive_reasoning_fields(thinking) if adaptive_thinking?(thinking, model)
22
+
23
+ effort = thinking.effort.to_s
24
+ budget = reasoning_budget(thinking, effort, model, max_output_tokens)
25
+ return { reasoning_config: { type: 'enabled', budget_tokens: budget } } if budget
26
+
27
+ format_effort_fields(effort, model) unless effort.empty?
28
+ end
29
+
30
+ def disabled_reasoning_fields(model)
31
+ return { thinking: { type: 'between_tools' } } if between_tools_off?(model)
32
+
33
+ { reasoning_config: { type: 'disabled' } }
34
+ end
35
+
36
+ def between_tools_off?(model)
37
+ return false unless model.respond_to?(:provider_class)
38
+
39
+ provider = model.provider_class
40
+ provider.respond_to?(:between_tools_off?) && provider.between_tools_off?(model.id)
41
+ end
42
+
43
+ # Models that publish a reasoning_config enum, such as OpenAI's GPT
44
+ # models, take the effort as its value and reject reasoning_effort.
45
+ def format_effort_fields(effort, model)
46
+ return { reasoning_config: effort } if reasoning_config_schema(model)
47
+
48
+ { reasoning_effort: effort } unless effort == 'none'
49
+ end
50
+
51
+ # Claude generations that take an effort but no budget only think
52
+ # adaptively, and reject a reasoning_config that enables a budget.
53
+ def adaptive_thinking?(thinking, model)
54
+ return false unless thinking.budget.nil?
55
+ return false unless Chat.foundation_model_id(model&.id).start_with?('anthropic.claude')
56
+
57
+ registered = registered_model(model)
58
+ !registered.nil? && !registered.reasoning_option(:effort).nil? &&
59
+ registered.reasoning_option(:budget_tokens).nil?
60
+ end
61
+
62
+ def format_adaptive_reasoning_fields(thinking)
63
+ effort = thinking.effort.to_s
64
+ return nil if effort == 'none'
65
+ return { thinking: { type: 'adaptive' } } if effort.empty?
66
+
67
+ { thinking: { type: 'adaptive' }, output_config: { effort: effort } }
68
+ end
69
+
70
+ # Inference profile ARNs and unlisted regional ids carry no reasoning
71
+ # options of their own, so use another entry for the same model.
72
+ def registered_model(model)
73
+ return model if model.reasoning_options.any?
74
+
75
+ foundation_id = Chat.foundation_model_id(model.id)
76
+ RubyLLM.models.all.find do |candidate|
77
+ candidate.provider == 'bedrock' && candidate.reasoning_options.any? &&
78
+ Chat.foundation_model_id(candidate.id) == foundation_id
79
+ end
80
+ end
81
+
82
+ def nova_model?(model)
83
+ Chat.foundation_model_id(model&.id).start_with?('amazon.nova')
84
+ end
85
+
86
+ # Nova 2 takes reasoningConfig with an effort level instead of a token budget,
87
+ # and rejects an enabled reasoningConfig that names no effort.
88
+ def format_nova_reasoning_fields(thinking, model)
89
+ if thinking.budget
90
+ raise ArgumentError,
91
+ "#{model&.id} takes a reasoning effort, not a token budget of #{thinking.budget}. " \
92
+ 'Pass with_thinking(effort:) instead.'
93
+ end
94
+
95
+ return { reasoningConfig: { type: 'disabled' } } if thinking.enabled == false
96
+
97
+ effort = thinking.effort.to_s
98
+ return nil if effort == 'none'
99
+
100
+ effort = NOVA_DEFAULT_REASONING_EFFORT if effort.empty?
101
+ { reasoningConfig: { type: 'enabled', maxReasoningEffort: effort } }
102
+ end
103
+
104
+ def reasoning_budget(thinking, effort, model, max_output_tokens)
105
+ return thinking.budget if thinking.budget.is_a?(Integer)
106
+ return nil if effort.empty? || effort == 'none'
107
+
108
+ schema = reasoning_budget_schema(model)
109
+ schema && effort_budget_tokens(effort, schema, max_output_tokens)
110
+ end
111
+
112
+ def reasoning_budget_schema(model)
113
+ request_fields_schema(model) { |schema| schema.dig(:reasoningConfig, :budgetTokens) }
114
+ end
115
+
116
+ def reasoning_config_schema(model)
117
+ request_fields_schema(model) do |schema|
118
+ config = schema[:reasoning_config]
119
+ config if config.is_a?(Hash) && config[:type] == 'enum'
120
+ end
121
+ end
122
+
123
+ # Bedrock only publishes Converse metadata for some regional entries, so use the
124
+ # schema from another entry for the same foundation model when needed.
125
+ def request_fields_schema(model, &)
126
+ schema = published_schema(model, &)
127
+ return schema if schema
128
+ return unless model
129
+
130
+ foundation_id = Chat.foundation_model_id(model.id)
131
+ RubyLLM.models.all.each do |candidate|
132
+ next unless candidate.provider == 'bedrock' && candidate.id != model.id
133
+ next unless Chat.foundation_model_id(candidate.id) == foundation_id
134
+
135
+ return schema if (schema = published_schema(candidate, &))
136
+ end
137
+
138
+ nil
139
+ end
140
+
141
+ def published_schema(model)
142
+ metadata = RubyLLM::Support::Utils.deep_symbolize_keys(model&.metadata || {})
143
+ raw_schema = metadata.dig(:converse, :additionalRequestFieldsSchema)
144
+ return unless raw_schema.is_a?(String)
145
+
146
+ schema = JSON.parse(raw_schema, symbolize_names: true)
147
+ found = schema.is_a?(Hash) ? yield(schema) : nil
148
+ found if found.is_a?(Hash)
149
+ rescue JSON::ParserError
150
+ nil
151
+ end
152
+
153
+ # Models that take a budget reject reasoning_effort, so effort has to become a budget.
154
+ # Bedrock names the levels of an enumerated budget after the efforts they stand for;
155
+ # otherwise the effort spans the range the schema allows.
156
+ def effort_budget_tokens(effort, schema, max_output_tokens)
157
+ budget = enumerated_budget(effort, schema) || ranged_budget(effort, schema)
158
+ return nil unless budget
159
+
160
+ minimum = schema[:minimum].is_a?(Integer) ? schema[:minimum] : MINIMUM_BUDGET_TOKENS
161
+ return [budget, minimum].max unless max_output_tokens
162
+
163
+ budget.clamp(minimum, [max_output_tokens - 1, minimum].max)
164
+ end
165
+
166
+ def enumerated_budget(effort, schema)
167
+ levels = schema[:enum]
168
+ level = levels.is_a?(Hash) ? levels[effort.to_sym] : nil
169
+ level if level.is_a?(Integer)
170
+ end
171
+
172
+ def ranged_budget(effort, schema)
173
+ minimum = schema[:minimum]
174
+ maximum = schema[:maximum]
175
+ return nil unless minimum.is_a?(Integer) && maximum.is_a?(Integer)
176
+
177
+ case effort
178
+ when 'low' then minimum
179
+ when 'medium' then minimum + ((maximum - minimum) / 2)
180
+ when 'high' then maximum
181
+ else raise ArgumentError, unknown_effort_message(effort, schema)
182
+ end
183
+ end
184
+
185
+ def unknown_effort_message(effort, schema)
186
+ levels = schema[:enum].is_a?(Hash) ? schema[:enum].keys.map(&:to_s) : []
187
+ levels |= RANGED_EFFORTS
188
+ "Bedrock has no reasoning budget for effort #{effort.inspect}. " \
189
+ "Use #{levels.join(', ')}, or pass an explicit budget."
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end