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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Anthropic
6
+ # Describes Messages API requests, and the token counting requests
7
+ # that share their shape, as RequestShape objects. Claude pairs tool
8
+ # results with calls by id and takes a thinking block back only with
9
+ # its signature.
10
+ module RequestShapes
11
+ MEDIA_KINDS = { 'image' => :image, 'document' => :document }.freeze
12
+ THINKING_SETTINGS = %w[type budget_tokens display].freeze
13
+
14
+ def parse_request_shape(payload)
15
+ messages = payload['messages']
16
+ return unless messages.is_a?(Array)
17
+
18
+ turns = messages.each_with_index.map { |message, index| shape_anthropic_turn(shape_hash(message), index) }
19
+ shape_request(turns, payload:, rules: %i[signed_thinking],
20
+ instructions: shape_anthropic_content(payload['system']),
21
+ tool_names: shape_list(payload['tools']).map { |tool| shape_tool_name(tool) },
22
+ thinking_settings: shape_anthropic_thinking(payload))
23
+ end
24
+
25
+ private
26
+
27
+ def shape_anthropic_thinking(payload)
28
+ effort = shape_settings(payload['output_config'], 'effort')
29
+ shape_settings(payload['thinking'], *THINKING_SETTINGS).merge(effort)
30
+ end
31
+
32
+ def shape_anthropic_turn(message, index)
33
+ parts = shape_anthropic_content(message['content'])
34
+ shape_turn(index, message['role'], message['role'] == 'assistant' ? :model : shape_answer(parts), parts)
35
+ end
36
+
37
+ def shape_anthropic_content(content)
38
+ return [shape_text(content)] if content.is_a?(String)
39
+
40
+ shape_list(content).flat_map { |block| shape_anthropic_block(shape_hash(block)) }
41
+ end
42
+
43
+ def shape_anthropic_block(block)
44
+ case block['type']
45
+ when 'text' then shape_text(block['text'])
46
+ when 'thinking' then shape_text(block['thinking'], kind: :thinking,
47
+ signed: shape_signature?(block['signature']))
48
+ when 'redacted_thinking' then shape_text(nil, kind: :thinking, signed: shape_signature?(block['data']))
49
+ when 'tool_use' then shape_tool_call(block['name'], block['input'], id: block['id'])
50
+ when 'tool_result'
51
+ shape_tool_result(block['content'], id: block['tool_use_id']) { |inner| shape_anthropic_block(inner) }
52
+ when *MEDIA_KINDS.keys then shape_anthropic_source(MEDIA_KINDS[block['type']], shape_hash(block['source']))
53
+ else shape_other(block['type'])
54
+ end
55
+ end
56
+
57
+ def shape_anthropic_source(kind, source)
58
+ case source['type']
59
+ when 'base64' then shape_inline(source['data'], kind:, mime_type: source['media_type'])
60
+ when 'text' then shape_media(kind, source: :inline, size: shape_length(source['data']),
61
+ mime_type: source['media_type'])
62
+ when 'url' then shape_media(kind, source: :url)
63
+ when 'file' then shape_file(kind:)
64
+ else shape_media(kind, source: :inline, size: shape_length(source['content']))
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -7,6 +7,19 @@ module RubyLLM
7
7
  class Anthropic
8
8
  # Streaming methods of the Anthropic API integration
9
9
  module Streaming
10
+ ERROR_STATUSES = {
11
+ 'invalid_request_error' => 400,
12
+ 'authentication_error' => 401,
13
+ 'billing_error' => 402,
14
+ 'permission_error' => 403,
15
+ 'not_found_error' => 404,
16
+ 'request_too_large' => 413,
17
+ 'rate_limit_error' => 429,
18
+ 'api_error' => 500,
19
+ 'timeout_error' => 504,
20
+ 'overloaded_error' => 529
21
+ }.freeze
22
+
10
23
  private
11
24
 
12
25
  def stream_response(payload, additional_headers = {}, &)
@@ -37,6 +50,7 @@ module RubyLLM
37
50
  thinking_tokens: extract_thinking_tokens(data),
38
51
  cache_read_tokens: extract_cache_read_tokens(data),
39
52
  cache_write_tokens: extract_cache_write_tokens(data),
53
+ cache_write_tokens_by_ttl: extract_cache_write_by_ttl(data),
40
54
  server_tool_use: extract_server_tool_use(data),
41
55
  tool_calls: extract_tool_calls(data),
42
56
  finish_reason: normalize_finish_reason(data.dig('delta', 'stop_reason')),
@@ -84,15 +98,20 @@ module RubyLLM
84
98
  return unless block
85
99
 
86
100
  case delta_type
87
- when 'text_delta' then block['text'] = block['text'].to_s + data.dig('delta', 'text').to_s
88
- when 'thinking_delta' then block['thinking'] = block['thinking'].to_s + data.dig('delta', 'thinking').to_s
101
+ when 'text_delta' then append_stream_text(block, 'text', data.dig('delta', 'text'))
102
+ when 'thinking_delta' then append_stream_text(block, 'thinking', data.dig('delta', 'thinking'))
89
103
  when 'signature_delta' then block['signature'] = data.dig('delta', 'signature')
90
104
  when 'input_json_delta' then @stream_block_json&.dig(data['index'])&.<< data.dig('delta', 'partial_json').to_s
91
105
  when 'citations_delta' then (block['citations'] ||= []) << data.dig('delta', 'citation')
92
- when 'compaction_delta' then block['content'] = block['content'].to_s + data.dig('delta', 'content').to_s
106
+ when 'compaction_delta' then append_stream_text(block, 'content', data.dig('delta', 'content'))
93
107
  end
94
108
  end
95
109
 
110
+ def append_stream_text(block, key, text)
111
+ block[key] = +block[key].to_s
112
+ block[key] << text.to_s
113
+ end
114
+
96
115
  def finalize_stream_block(index)
97
116
  json = @stream_block_json&.dig(index)
98
117
  block = @stream_blocks&.dig(index)
@@ -154,12 +173,7 @@ module RubyLLM
154
173
  error = error_data['error']
155
174
  return [500, error.to_s] unless error.is_a?(Hash)
156
175
 
157
- case error['type']
158
- when 'overloaded_error'
159
- [529, error['message']]
160
- else
161
- [500, error['message']]
162
- end
176
+ [ERROR_STATUSES.fetch(error['type'], 500), error['message']]
163
177
  end
164
178
  end
165
179
  end
@@ -7,10 +7,21 @@ module RubyLLM
7
7
  module Tools
8
8
  module_function
9
9
 
10
+ NATIVE_TOOL_SEARCH = {
11
+ type: 'tool_search_tool_bm25_20251119',
12
+ name: 'tool_search_tool_bm25'
13
+ }.freeze
14
+
10
15
  def find_tool_uses(blocks)
11
16
  blocks.select { |c| c['type'] == 'tool_use' }
12
17
  end
13
18
 
19
+ def tool_search_block?(block)
20
+ return true if block['type'] == 'tool_search_tool_result'
21
+
22
+ block['type'] == 'server_tool_use' && block['name'].to_s.start_with?('tool_search_tool_')
23
+ end
24
+
14
25
  def format_tool_result(msg)
15
26
  {
16
27
  role: 'user',
@@ -56,12 +67,44 @@ module RubyLLM
56
67
  description: tool.description,
57
68
  input_schema: input_schema || default_input_schema
58
69
  }
70
+ declaration[:defer_loading] = true if deferred?(tool)
59
71
 
60
72
  return declaration if tool.provider_options.empty?
61
73
 
62
74
  RubyLLM::Support::Utils.deep_merge(declaration, tool.provider_options)
63
75
  end
64
76
 
77
+ def deferred?(tool)
78
+ tool.is_a?(RubyLLM::Tool::Deferred)
79
+ end
80
+
81
+ # The API requires a search tool next to deferred tools, and rejects
82
+ # search blocks in the history of a request that declares none.
83
+ def apply_tool_search(payload)
84
+ tools = Array(payload[:tools])
85
+ return payload if tools.any? { |tool| search_tool?(tool) }
86
+ return payload.merge(tools: tools + [NATIVE_TOOL_SEARCH.dup]) if defers_loading?(tools)
87
+
88
+ payload.merge(messages: without_tool_search_blocks(payload[:messages]))
89
+ end
90
+
91
+ def defers_loading?(tools)
92
+ tools.any? { |tool| tool[:defer_loading] || tool['defer_loading'] }
93
+ end
94
+
95
+ def search_tool?(tool)
96
+ (tool[:type] || tool['type']).to_s.start_with?('tool_search_tool_')
97
+ end
98
+
99
+ def without_tool_search_blocks(messages)
100
+ messages.filter_map do |message|
101
+ next message unless message[:content].is_a?(Array)
102
+
103
+ content = message[:content].reject { |block| tool_search_block?(block) }
104
+ message.merge(content:) unless content.empty?
105
+ end
106
+ end
107
+
65
108
  def extract_tool_calls(data)
66
109
  if json_delta?(data)
67
110
  extract_tool_call_delta(data)
@@ -8,6 +8,7 @@ module RubyLLM
8
8
  include Anthropic::Embeddings
9
9
  include Anthropic::Media
10
10
  include Anthropic::Models
11
+ include Anthropic::RequestShapes
11
12
  include Anthropic::Streaming
12
13
  include Anthropic::Tools
13
14
 
@@ -15,6 +16,8 @@ module RubyLLM
15
16
  # continued before RubyLLM returns what it has.
16
17
  MAX_PAUSE_TURN_CONTINUATIONS = 8
17
18
 
19
+ DEFAULT_MAX_OUTPUT_TOKENS = 4096
20
+
18
21
  # web_search/web_fetch pin allowed_callers to direct invocation: their
19
22
  # 2026 tool versions otherwise default to requiring programmatic tool
20
23
  # calling, which not every model supports.
@@ -28,13 +31,29 @@ module RubyLLM
28
31
  name = options[:name] || 'mcp'
29
32
  server = { type: 'url', name: name }.merge(options.slice(:url, :authorization_token))
30
33
  {
31
- tool: { type: 'mcp_toolset', mcp_server_name: name }.merge(options.slice(:default_config, :configs)),
34
+ tool: { type: 'mcp_toolset', mcp_server_name: name }
35
+ .merge(Anthropic.mcp_tool_filter(options))
36
+ .merge(options.slice(:default_config, :configs)),
32
37
  payload: { mcp_servers: [server] },
33
38
  headers: { 'anthropic-beta' => 'mcp-client-2025-11-20' }
34
39
  }
35
40
  end
36
41
  }.freeze
37
42
 
43
+ def self.mcp_tool_filter(options) # :nodoc:
44
+ approval = options[:require_approval]
45
+ unless approval.nil? || approval.to_s == 'never'
46
+ raise ArgumentError, "Anthropic runs MCP tools without approval; use require_approval: 'never'"
47
+ end
48
+
49
+ tools = options[:allowed_tools]
50
+ tools = tools[:tool_names] if tools.is_a?(Hash) && tools.keys == [:tool_names]
51
+ return {} if tools.nil?
52
+ raise ArgumentError, 'Anthropic filters MCP tools by name; use allowed_tools: [name]' unless tools.is_a?(Array)
53
+
54
+ { default_config: { enabled: false }, configs: tools.to_h { |tool| [tool.to_sym, { enabled: true }] } }
55
+ end
56
+
38
57
  def server_tool_aliases
39
58
  SERVER_TOOL_ALIASES
40
59
  end
@@ -5,18 +5,12 @@ module RubyLLM
5
5
  module Bedrock
6
6
  # Luma Ray 2 video generation through Bedrock asynchronous invocation.
7
7
  class AsyncVideos < Protocol
8
- IMAGE_TYPES = %w[image/png image/jpeg].freeze
9
-
10
8
  def video_url
11
9
  '/async-invoke'
12
10
  end
13
11
 
14
12
  def render_video_payload(prompt, model:, with: [], provider_options: {})
15
13
  output_uri = video_output_uri
16
- unless prompt.is_a?(String) && (1..5000).cover?(prompt.length)
17
- raise ArgumentError, 'Luma Ray 2 requires a prompt between 1 and 5000 characters'
18
- end
19
-
20
14
  options = Support::Utils.deep_symbolize_keys(provider_options)
21
15
  outer = options.slice(:clientRequestToken, :tags)
22
16
  input = { prompt: prompt }.merge(render_keyframes(with)).merge(options.except(*outer.keys))
@@ -25,16 +19,18 @@ module RubyLLM
25
19
  end
26
20
 
27
21
  def post_video(url, payload)
28
- @connection.post(url, payload, idempotent: false) do |request|
29
- request.headers.merge!(@provider.sign_headers('POST', url, JSON.generate(payload)))
30
- end
22
+ @connection.post(url, JSON.generate(payload), idempotent: false)
31
23
  end
32
24
 
33
- def parse_video_job(response, model:)
25
+ def parse_video_job(response, model:, **request)
34
26
  id = response.body['invocationArn']
35
27
  raise Error.new('Bedrock did not return a video invocation ARN', response:) unless id
36
28
 
37
- VideoJob.new(id: id, protocol: self, model: model, raw: response.body)
29
+ VideoJob.new(id: id, protocol: self, model: model, raw: response.body, **request)
30
+ end
31
+
32
+ def video_request_settings(request)
33
+ super(request[:modelInput])
38
34
  end
39
35
 
40
36
  def video_job_url(job)
@@ -42,11 +38,7 @@ module RubyLLM
42
38
  end
43
39
 
44
40
  def refresh_video_job(job)
45
- url = video_job_url(job)
46
- response = @connection.get(url) do |request|
47
- request.headers.merge!(@provider.sign_headers('GET', url, ''))
48
- end
49
- parse_video_job_status(response, job:)
41
+ parse_video_job_status(@connection.get(video_job_url(job)), job:)
50
42
  end
51
43
 
52
44
  def parse_video_job_status(response, **)
@@ -91,7 +83,7 @@ module RubyLLM
91
83
  raise ArgumentError, 'Luma Ray 2 requires image bytes or URLs, not uploaded file ids'
92
84
  end
93
85
 
94
- raise UnsupportedAttachmentError, attachment.mime_type unless IMAGE_TYPES.include?(attachment.mime_type)
86
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
95
87
  end
96
88
  end
97
89
 
@@ -14,9 +14,8 @@ module RubyLLM
14
14
  }.freeze
15
15
  private_constant :POLICY_FILTERS
16
16
 
17
- IMAGE_TYPES = %w[image/png image/jpeg].freeze
18
17
  INTERVENTIONS = %w[BLOCKED ANONYMIZED].freeze
19
- private_constant :IMAGE_TYPES, :INTERVENTIONS
18
+ private_constant :INTERVENTIONS
20
19
 
21
20
  def moderate(input, model:, with: [], provider_options: {})
22
21
  raise ArgumentError, 'Bedrock guardrails do not accept a model' unless model.nil?
@@ -27,7 +26,7 @@ module RubyLLM
27
26
  payloads = (input.is_a?(Array) ? input : [input]).map do |text|
28
27
  render_moderation_payload(text, attachments, provider_options)
29
28
  end
30
- track_usage(:moderation) do
29
+ track_usage(:moderation, model_id: guardrail_model_id) do
31
30
  responses = payloads.map do |payload|
32
31
  response = post_moderation(url, payload)
33
32
  result = parse_moderation_result(response)
@@ -40,6 +39,10 @@ module RubyLLM
40
39
 
41
40
  private
42
41
 
42
+ def guardrail_model_id
43
+ "#{@config.bedrock_guardrail_id}:#{@config.bedrock_guardrail_version}"
44
+ end
45
+
43
46
  def parse_moderation_responses(responses)
44
47
  RubyLLM::Moderation.new(
45
48
  id: responses.one? ? responses.first.first.headers['x-amzn-requestid'] : nil,
@@ -59,9 +62,7 @@ module RubyLLM
59
62
  raise ArgumentError, 'Bedrock moderation requires text or an image' if input.nil? && attachments.empty?
60
63
 
61
64
  attachments.each do |attachment|
62
- next if IMAGE_TYPES.include?(attachment.mime_type)
63
-
64
- raise UnsupportedAttachmentError, attachment.mime_type
65
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
65
66
  end
66
67
  end
67
68
 
@@ -88,9 +89,7 @@ module RubyLLM
88
89
  end
89
90
 
90
91
  def post_moderation(url, payload)
91
- @connection.post(url, payload, usage: @usage_tracker) do |request|
92
- request.headers.merge!(@provider.sign_headers('POST', url, JSON.generate(payload)))
93
- end
92
+ @connection.post(url, JSON.generate(payload), usage: @usage_tracker)
94
93
  end
95
94
 
96
95
  def parse_moderation_result(response)
@@ -16,7 +16,6 @@ module RubyLLM
16
16
  private
17
17
 
18
18
  def render_rerank_payload(query, documents, model:, top_n:, provider_options:)
19
- validate_rerank_input(query, documents, top_n)
20
19
  payload = {
21
20
  queries: [{ type: 'TEXT', textQuery: { text: query } }],
22
21
  sources: documents.map { |document| render_rerank_source(document) },
@@ -31,18 +30,6 @@ module RubyLLM
31
30
  Support::Utils.deep_merge(payload, provider_options)
32
31
  end
33
32
 
34
- def validate_rerank_input(query, documents, top_n)
35
- unless query.is_a?(String) && !query.empty?
36
- raise ArgumentError, 'Bedrock reranking requires one nonempty text query'
37
- end
38
- unless documents.is_a?(Array) && (1..1000).cover?(documents.length)
39
- raise ArgumentError, 'Bedrock reranking accepts between 1 and 1000 documents'
40
- end
41
- return if top_n.nil? || (top_n.is_a?(Integer) && (1..1000).cover?(top_n))
42
-
43
- raise ArgumentError, 'Bedrock reranking top_n must be between 1 and 1000'
44
- end
45
-
46
33
  def render_rerank_source(document)
47
34
  content = case document
48
35
  when String then { type: 'TEXT', textDocument: { text: document } }
@@ -70,10 +57,7 @@ module RubyLLM
70
57
  end
71
58
 
72
59
  def post_rerank(payload)
73
- @provider.agent_connection.post('/rerank', payload, usage: @usage_tracker) do |request|
74
- request.headers.merge!(@provider.sign_headers('POST', '/rerank', JSON.generate(payload),
75
- base_url: @provider.agent_api_base))
76
- end
60
+ @provider.agent_connection.post('/rerank', JSON.generate(payload), usage: @usage_tracker)
77
61
  end
78
62
 
79
63
  def parse_rerank_pages(pages, model:, documents:)
@@ -11,7 +11,6 @@ module RubyLLM
11
11
  }.freeze
12
12
 
13
13
  OPENAI_INLINE_FILE_LIMIT = 50 * 1024 * 1024
14
- OPENAI_FILE_UPLOAD_LIMIT = 512 * 1024 * 1024
15
14
  PROMPT_CACHE_OPTIONS = %i[key ttl mode retention].freeze
16
15
 
17
16
  def completion_url
@@ -106,7 +105,7 @@ module RubyLLM
106
105
  end
107
106
 
108
107
  def parse_completion_body(data, raw:)
109
- raise Error.new(data.dig('error', 'message'), response: raw) if data.dig('error', 'message')
108
+ raise_stream_error(JSON.generate(data), data, nil) if data.dig('error', 'message')
110
109
 
111
110
  message_data = data.dig('choices', 0, 'message')
112
111
  raise no_completion_message_error(data, raw) unless message_data
@@ -143,8 +142,11 @@ module RubyLLM
143
142
  nil
144
143
  end
145
144
 
145
+ # Per-tool counters end in _requests; OpenRouter's tool_calls_requested
146
+ # and tool_calls_executed count every tool at once.
146
147
  def server_tool_use(usage)
147
- usage['server_tool_use'] || usage['server_tool_use_details']
148
+ counters = usage['server_tool_use'] || usage['server_tool_use_details']
149
+ counters.to_h.select { |counter, _| counter.end_with?('_requests') }
148
150
  end
149
151
 
150
152
  def no_completion_message_error(data, raw)
@@ -404,10 +406,6 @@ module RubyLLM
404
406
  OPENAI_INLINE_FILE_LIMIT
405
407
  end
406
408
 
407
- def provider_file_upload_limit
408
- OPENAI_FILE_UPLOAD_LIMIT
409
- end
410
-
411
409
  def provider_file_attachable?(attachment)
412
410
  attachment.pdf?
413
411
  end
@@ -7,6 +7,20 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment, document_attachments: :pdf, image_attachments: true,
11
+ audio_attachments: true)
12
+ return document_attachments != :none if attachment.provider_file?
13
+
14
+ case attachment.type
15
+ when :image then image_attachments
16
+ when :audio then audio_attachments
17
+ when :text then true
18
+ when :pdf, :document
19
+ document_attachments == :all || (document_attachments == :pdf && attachment.pdf?)
20
+ else false
21
+ end
22
+ end
23
+
10
24
  def format_content(content, attachments = [], document_attachments: :pdf, image_attachments: true,
11
25
  audio_attachments: true)
12
26
  format_parts(content, attachments) do |attachment|
@@ -35,23 +49,21 @@ module RubyLLM
35
49
  end
36
50
 
37
51
  def format_attachment(attachment, document_attachments:, image_attachments:, audio_attachments:)
52
+ unless supported_attachment?(attachment, document_attachments:, image_attachments:, audio_attachments:)
53
+ raise UnsupportedAttachmentError, attachment.mime_type
54
+ end
55
+
38
56
  return format_provider_file(attachment, document_attachments:) if attachment.provider_file?
39
57
 
40
58
  case attachment.type
41
59
  when :image
42
- raise UnsupportedAttachmentError, attachment.mime_type unless image_attachments
43
-
44
- format_image(attachment)
60
+ with_image_detail(format_image(attachment), attachment)
45
61
  when :audio
46
- raise UnsupportedAttachmentError, attachment.mime_type unless audio_attachments
47
-
48
62
  format_audio(attachment)
49
63
  when :pdf, :document
50
64
  format_document_attachment(attachment, document_attachments)
51
65
  when :text
52
66
  format_text_file(attachment)
53
- else
54
- raise UnsupportedAttachmentError, attachment.mime_type
55
67
  end
56
68
  end
57
69
 
@@ -59,11 +71,25 @@ module RubyLLM
59
71
  {
60
72
  type: 'image_url',
61
73
  image_url: {
62
- url: image.url? ? image.source.to_s : image.for_llm
74
+ url: image.url_or_data_uri
63
75
  }
64
76
  }
65
77
  end
66
78
 
79
+ def with_image_detail(part, image, original_detail: @provider&.original_image_detail?)
80
+ return part unless image.resolution
81
+
82
+ part[:image_url][:detail] = image_detail(image.resolution, original_detail:)
83
+ part
84
+ end
85
+
86
+ def image_detail(resolution, original_detail: false)
87
+ return 'low' if resolution == :low
88
+ return 'original' if resolution == :original && original_detail
89
+
90
+ 'high'
91
+ end
92
+
67
93
  def format_document(document)
68
94
  {
69
95
  type: 'file',
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class ChatCompletions
6
+ # Describes Chat Completions requests as RequestShape objects. Cohere
7
+ # v2 sends messages in the same shape. Tool messages answer calls by id.
8
+ module RequestShapes
9
+ SPEAKERS = {
10
+ 'system' => :system, 'developer' => :system, 'assistant' => :model, 'tool' => :tool, 'function' => :tool
11
+ }.freeze
12
+
13
+ def parse_request_shape(payload)
14
+ messages = payload['messages']
15
+ return unless messages.is_a?(Array)
16
+
17
+ turns = messages.each_with_index.map { |message, index| shape_message(shape_hash(message), index) }
18
+ tool_names = shape_list(payload['tools']).map { |tool| shape_function_name(tool) }
19
+ shape_request(turns, payload:, tool_names:, thinking_settings: shape_reasoning_settings(payload))
20
+ end
21
+
22
+ private
23
+
24
+ def shape_message(message, index)
25
+ speaker = SPEAKERS.fetch(message['role'], :user)
26
+ content = message['content']
27
+ parts = shape_message_thinking(message)
28
+ if speaker == :tool
29
+ parts.concat(shape_tool_result(content, name: message['name'], id: message['tool_call_id']) do |block|
30
+ shape_content_part(block)
31
+ end)
32
+ else
33
+ parts.concat(shape_content(content))
34
+ end
35
+ parts.concat(shape_list(message['tool_calls']).map { |call| shape_function_call(shape_hash(call)) })
36
+ shape_turn(index, message['role'], speaker, parts)
37
+ end
38
+
39
+ def shape_reasoning_settings(payload)
40
+ shape_settings(payload, 'reasoning_effort')
41
+ .merge(shape_settings(payload['reasoning'], 'effort', 'max_tokens', 'enabled', 'exclude'))
42
+ .merge(shape_settings(payload['thinking'], 'type', 'token_budget', 'budget_tokens'))
43
+ end
44
+
45
+ def shape_message_thinking(message)
46
+ details = message['reasoning_details']
47
+ return details.map { |detail| shape_reasoning_detail(shape_hash(detail)) } if details.is_a?(Array)
48
+
49
+ text = message['reasoning_content'] || message['reasoning']
50
+ signed = shape_signature?(message['reasoning_signature'])
51
+ text || signed ? [shape_text(text, kind: :thinking, signed:)] : []
52
+ end
53
+
54
+ def shape_reasoning_detail(detail)
55
+ case detail['type']
56
+ when 'reasoning.encrypted' then shape_text(nil, kind: :thinking, signed: shape_signature?(detail['data']))
57
+ when 'reasoning.summary' then shape_text(detail['summary'], kind: :thinking)
58
+ else shape_text(detail['text'], kind: :thinking, signed: shape_signature?(detail['signature']))
59
+ end
60
+ end
61
+
62
+ def shape_content(content)
63
+ return [shape_text(content)] if content.is_a?(String)
64
+
65
+ shape_list(content).map { |part| shape_content_part(shape_hash(part)) }
66
+ end
67
+
68
+ def shape_content_part(part)
69
+ case part['type']
70
+ when 'text' then shape_text(part['text'])
71
+ when 'image_url' then shape_url(shape_url_field(part['image_url']), kind: :image)
72
+ when 'video_url' then shape_url(shape_url_field(part['video_url']), kind: :video)
73
+ when 'input_audio' then shape_inline(shape_hash(part['input_audio'])['data'], kind: :audio)
74
+ when 'file' then shape_content_file(shape_hash(part['file']))
75
+ when 'document_url' then shape_url(shape_url_field(part['document_url']), kind: :document)
76
+ when 'file_url' then shape_file_url(shape_url_field(part['file_url']))
77
+ when 'thinking' then shape_content_thinking(part)
78
+ else shape_other(part['type'])
79
+ end
80
+ end
81
+
82
+ def shape_url_field(value)
83
+ value.is_a?(Hash) ? value['url'] : value
84
+ end
85
+
86
+ def shape_content_file(file)
87
+ return shape_file(kind: :document) if file.key?('file_id')
88
+
89
+ shape_url(file['file_data'], kind: :document)
90
+ end
91
+
92
+ # Perplexity sends a document's bytes as bare base64 where its link
93
+ # would go, and base64 never holds the colon that starts a scheme.
94
+ def shape_file_url(url)
95
+ return shape_inline(url, kind: :document) if url.is_a?(String) && !url.include?(':')
96
+
97
+ shape_url(url, kind: :document)
98
+ end
99
+
100
+ def shape_content_thinking(part)
101
+ thinking = part['thinking']
102
+ signed = shape_signature?(part['signature'])
103
+ return shape_summary(thinking, signed:) if thinking.is_a?(Array)
104
+
105
+ shape_text(thinking, kind: :thinking, signed:)
106
+ end
107
+
108
+ def shape_function_call(call)
109
+ function = shape_hash(call['function'])
110
+ signature = shape_hash(shape_hash(call['extra_content'])['google'])['thought_signature']
111
+ shape_tool_call(function['name'], function['arguments'], id: call['id'], signed: shape_signature?(signature))
112
+ end
113
+
114
+ def shape_function_name(tool)
115
+ tool = shape_hash(tool)
116
+ shape_hash(tool['function'])['name'] || tool['type']
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -54,6 +54,9 @@ module RubyLLM
54
54
  error = error_data['error']
55
55
  return [nil, error.to_s] unless error.is_a?(Hash)
56
56
 
57
+ code = error['code']
58
+ return [code, error['message']] if code.is_a?(Integer) && code.between?(400, 599)
59
+
57
60
  case error['type']
58
61
  when 'server_error'
59
62
  [500, error['message']]
@@ -8,6 +8,7 @@ module RubyLLM
8
8
  include ChatCompletions::Embeddings
9
9
  include ChatCompletions::Models
10
10
  include ChatCompletions::Moderation
11
+ include ChatCompletions::RequestShapes
11
12
  include ChatCompletions::Streaming
12
13
  include ChatCompletions::Tools
13
14
  include ChatCompletions::Images