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
@@ -31,7 +31,11 @@ module RubyLLM
31
31
 
32
32
  usage = parse_conversation_usage(data['usage'] || {}).transform_keys(&:to_s)
33
33
  attachments.each_with_index.map do |attachment, index|
34
- bytes = @provider.download_file(attachment.provider_file_id)
34
+ bytes = if attachment.provider_file?
35
+ @provider.download_file(attachment.provider_file_id)
36
+ else
37
+ attachment.content
38
+ end
35
39
  Image.new(data: Base64.strict_encode64(bytes), mime_type: RubyLLM::Files::MimeType.for(StringIO.new(bytes)),
36
40
  model: model, usage: index.zero? ? usage : {})
37
41
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Mistral
6
+ class Conversations
7
+ # Describes Conversations requests as RequestShape objects. Each
8
+ # input entry is a turn, messages carry Chat Completions content, and
9
+ # a function result answers its call by id.
10
+ module RequestShapes
11
+ def parse_request_shape(payload)
12
+ inputs = payload['inputs']
13
+ return unless inputs.is_a?(Array)
14
+
15
+ turns = inputs.each_with_index.map { |entry, index| shape_conversation_turn(shape_hash(entry), index) }
16
+ shape_request(turns, payload:, instructions: shape_instructions(payload['instructions']),
17
+ tool_names: shape_list(payload['tools']).map { |tool| shape_function_name(tool) },
18
+ thinking_settings: shape_settings(payload['completion_args'], 'reasoning_effort'))
19
+ end
20
+
21
+ private
22
+
23
+ def shape_conversation_turn(entry, index)
24
+ role = entry['role'] || entry['type']
25
+ case entry['type']
26
+ when 'message.input'
27
+ speaker = ChatCompletions::RequestShapes::SPEAKERS.fetch(entry['role'], :user)
28
+ shape_turn(index, role, speaker, shape_content(entry['content']))
29
+ when 'message.output' then shape_turn(index, role, :model, shape_content(entry['content']))
30
+ when 'function.result'
31
+ shape_turn(index, role, :tool, shape_tool_result(entry['result'], id: entry['tool_call_id']))
32
+ else shape_turn(index, role, :model, [shape_conversation_step(entry)])
33
+ end
34
+ end
35
+
36
+ def shape_conversation_step(entry)
37
+ return shape_other(entry['type']) unless entry['type'] == 'function.call'
38
+
39
+ shape_tool_call(entry['name'], entry['arguments'], id: entry['tool_call_id'])
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -9,6 +9,7 @@ module RubyLLM
9
9
  include Conversations::Chat
10
10
  include Conversations::Streaming
11
11
  include Conversations::Images
12
+ include Conversations::RequestShapes
12
13
 
13
14
  public :render
14
15
 
@@ -55,9 +55,10 @@ module RubyLLM
55
55
  end.to_h
56
56
  calls = messages.flat_map { |message| Array(message['tool_calls']) }
57
57
  pending = multi_pending_calls(calls, results, raw:)
58
+ attachments = content[:attachments] + parse_multi_images(calls, results)
58
59
  usage = data['usage'] || {}
59
60
  Message.new(role: :assistant, content: content[:text], thinking: Thinking.build(text: content[:thinking]),
60
- attachments: content[:attachments], citations: content[:citations],
61
+ attachments: attachments.uniq(&:source), citations: content[:citations],
61
62
  tool_calls: parse_tool_calls(pending, response: raw, finish_reason: :tool_calls),
62
63
  server_tool_calls: parse_multi_steps(calls, results), raw_content: messages,
63
64
  input_tokens: input_tokens(usage), output_tokens: output_tokens(usage),
@@ -65,6 +66,15 @@ module RubyLLM
65
66
  finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason')))
66
67
  end
67
68
 
69
+ def parse_multi_images(calls, results)
70
+ calls.filter_map do |call|
71
+ result = results[call['id']]
72
+ next unless result && call.dig('metadata', 'tool_type') == 'image'
73
+
74
+ parse_generated_image(result['content'])
75
+ end
76
+ end
77
+
68
78
  def parse_multi_steps(calls, results)
69
79
  calls.filter_map do |call|
70
80
  result = results[call['id']]
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module OpenAI
6
+ # OpenAI Decisions: typed questions answered with probabilities.
7
+ class Decisions < Protocol
8
+ TYPES = { probability: 'predicate', choice: 'choice', score: 'score' }.freeze
9
+ OUTCOMES = { 'yes' => 'Yes', 'true' => 'Yes', 'no' => 'No', 'false' => 'No' }.freeze
10
+
11
+ def judgment_url
12
+ 'decisions'
13
+ end
14
+
15
+ def render_judgment_payload(input, questions:, model:, with: [], provider_options: {})
16
+ reserved = provider_options.keys.map(&:to_s) & %w[model input questions]
17
+ unless reserved.empty?
18
+ raise ArgumentError, "Use the judgment arguments instead of provider_options for #{reserved.join(', ')}"
19
+ end
20
+
21
+ {
22
+ model:,
23
+ input: render_input(input, with),
24
+ questions: questions.values.map { |question| render_question(question) }
25
+ }.merge(provider_options)
26
+ end
27
+
28
+ def parse_judgment_response(response, questions:)
29
+ body = response.body
30
+ answers = questions.values.zip(body.fetch('answers')).to_h do |question, answer|
31
+ [question.name, parse_answer(answer, question)]
32
+ end
33
+ RubyLLM::Judgment.new(answers:, model: body.fetch('model'), raw: response, model_info: @model,
34
+ tokens: parse_tokens(body['usage']))
35
+ rescue KeyError, NoMethodError, TypeError => e
36
+ raise Error.new("OpenAI Decisions returned an invalid judgment: #{e.message}", response:)
37
+ end
38
+
39
+ private
40
+
41
+ def render_input(input, attachments)
42
+ text = describe(input)
43
+ return text if attachments.empty?
44
+
45
+ content = attachments.map { |attachment| render_image(attachment) }
46
+ content.unshift({ type: 'input_text', text: }) unless text.nil? || text.empty?
47
+ [{ type: 'message', role: 'user', content: }]
48
+ end
49
+
50
+ def render_image(image)
51
+ raise UnsupportedAttachmentError, image.mime_type unless image.image?
52
+ return { type: 'input_image', file_id: image.provider_file_id } if image.provider_file?
53
+
54
+ Responses::Media.format_image(image, image_url: image.for_llm,
55
+ original_detail: @provider.original_image_detail?)
56
+ end
57
+
58
+ def render_question(question)
59
+ question_payload = { type: TYPES.fetch(question.type), name: question.name.to_s,
60
+ instructions: render_instructions(question) }.compact
61
+
62
+ case question.type
63
+ when :choice then question_payload.merge(choices: render_choices(question))
64
+ when :score then question_payload.merge(levels: render_levels(question))
65
+ else question_payload
66
+ end
67
+ end
68
+
69
+ def render_instructions(question)
70
+ lines = [describe(question.instructions)]
71
+ if question.type == :probability && question.criteria
72
+ question.criteria.each do |outcome, description|
73
+ lines << "#{OUTCOMES.fetch(outcome.to_s)}: #{describe(description)}" unless description.nil?
74
+ end
75
+ end
76
+ instructions = lines.compact.join("\n")
77
+ instructions unless instructions.empty?
78
+ end
79
+
80
+ def render_choices(question)
81
+ question.criteria.map do |value, description|
82
+ { value: value.to_s, description: describe(description) }.compact
83
+ end
84
+ end
85
+
86
+ def render_levels(question)
87
+ question.criteria.each_with_index.map do |description, index|
88
+ { label: index.to_s, description: describe(description) }
89
+ end
90
+ end
91
+
92
+ def describe(value)
93
+ value.nil? || value.is_a?(String) ? value : JSON.generate(value)
94
+ end
95
+
96
+ def parse_answer(answer, question)
97
+ case question.type
98
+ when :probability then RubyLLM::Probability.new(probability: answer.fetch('probability'))
99
+ when :choice then parse_choice(answer, question)
100
+ when :score then parse_score(answer, question)
101
+ end
102
+ end
103
+
104
+ def parse_choice(answer, question)
105
+ options = question.criteria.keys.to_h { |key| [key.to_s, key] }
106
+ RubyLLM::Choice.new(
107
+ choice: options.fetch(answer.fetch('choice')),
108
+ probabilities: parse_distribution(answer.fetch('probabilities'), 'value', options),
109
+ confidence: answer.fetch('confidence')
110
+ )
111
+ end
112
+
113
+ def parse_score(answer, question)
114
+ indexes = question.criteria.each_index.to_h { |index| [index.to_s, index] }
115
+ RubyLLM::Score.new(
116
+ score: answer.fetch('score'), levels: question.criteria,
117
+ probabilities: parse_distribution(answer.fetch('probabilities'), 'label', indexes),
118
+ confidence: answer.fetch('confidence')
119
+ )
120
+ end
121
+
122
+ def parse_distribution(entries, key, names)
123
+ entries.to_h { |entry| [names.fetch(entry.fetch(key)), entry.fetch('probability')] }
124
+ end
125
+
126
+ def parse_tokens(usage)
127
+ tokens = Responses::Chat.parse_usage(usage || {})
128
+ Tokens.new(input: tokens[:input_tokens], output: tokens[:output_tokens],
129
+ cache_read: tokens[:cache_read_tokens], cache_write: tokens[:cache_write_tokens],
130
+ thinking: tokens[:thinking_tokens])
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -5,18 +5,11 @@ module RubyLLM
5
5
  module OpenAI
6
6
  # OpenAI Files API.
7
7
  class Files < Protocols::Files
8
- UPLOAD_PURPOSES = %w[assistants batch fine-tune vision user_data evals].freeze
9
-
10
8
  private
11
9
 
12
10
  # rubocop:disable-next Lint/UnusedMethodArgument
13
11
  def render_upload_payload(attachment, purpose: nil, expires_in: nil, visibility: nil,
14
12
  display_name: nil, uri: nil, content_type: nil)
15
- unless purpose
16
- raise ArgumentError, "#{@provider.name} file uploads require purpose: " \
17
- "#{UPLOAD_PURPOSES.join(', ')}"
18
- end
19
-
20
13
  multipart_payload(attachment, purpose:, expires_after: expires_after(expires_in))
21
14
  end
22
15
 
@@ -34,12 +34,7 @@ module RubyLLM
34
34
 
35
35
  def batch_results(id)
36
36
  data = batch_data(id)
37
- results = Array(data['results']).map { |row| parse_batch_result(row, data:) }
38
- unless results.map(&:first).uniq.size == results.size
39
- raise Error, 'OpenRouter returned duplicate batch request IDs'
40
- end
41
-
42
- results
37
+ Array(data['results']).map { |row| parse_batch_result(row, data:) }
43
38
  end
44
39
 
45
40
  private
@@ -125,8 +120,18 @@ module RubyLLM
125
120
  return [index, nil, batch_failure(custom_id, batch_error_message(row))]
126
121
  end
127
122
 
128
- [index, parse_batch_body(response.fetch('body'), endpoint: data.fetch('endpoint'),
129
- model: data.fetch('model'), shape:)]
123
+ body = response.fetch('body')
124
+ endpoint = data.fetch('endpoint')
125
+ if endpoint == '/v1/embeddings' && !embedding_positions?(body)
126
+ return [index, nil, batch_failure(custom_id, 'Invalid or duplicate embedding record positions')]
127
+ end
128
+
129
+ [index, parse_batch_body(body, endpoint:, model: data.fetch('model'), shape:)]
130
+ end
131
+
132
+ def embedding_positions?(body)
133
+ positions = body.fetch('data').map { |row| row['index'] }
134
+ positions.all?(Integer) && positions.sort == (0...positions.size).to_a
130
135
  end
131
136
 
132
137
  def parse_batch_body(body, endpoint:, model:, shape:)
@@ -30,7 +30,11 @@ module RubyLLM
30
30
  end
31
31
 
32
32
  def parse_usage(usage)
33
- super.merge(reported_cost: reported_cost(usage), server_tool_use: server_tool_use(usage))
33
+ super.merge(reported_cost: reported_cost(usage))
34
+ end
35
+
36
+ def parse_server_tool_use(response)
37
+ server_tool_use(response['usage'].to_h)
34
38
  end
35
39
 
36
40
  def reported_cost(usage)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Perplexity
6
+ # Perplexity's Agent API, a dialect of the Responses API. A request names
7
+ # either a preset, which bundles a model, search settings, and
8
+ # instructions, or a model in provider/model form. The retired Sonar
9
+ # model ids run the presets Perplexity recommends in their place.
10
+ class Agent < Responses
11
+ PRESETS = %w[fast low medium high xhigh wide-research].freeze
12
+ SONAR_PRESETS = {
13
+ 'sonar' => 'fast',
14
+ 'sonar-pro' => 'low',
15
+ 'sonar-reasoning-pro' => 'medium',
16
+ 'sonar-deep-research' => 'high'
17
+ }.freeze
18
+ SERVER_TOOL_USAGE_NAMES = { 'search_web' => 'web_search', 'fetch_url' => 'web_fetch' }.freeze
19
+
20
+ def completion_url
21
+ @provider.agent_url
22
+ end
23
+
24
+ def render_payload(messages, model:, max_output_tokens: nil, **)
25
+ max_output_tokens ||= default_max_output_tokens(model)
26
+ payload = super
27
+ preset = preset_for(model.id)
28
+ return payload unless preset
29
+
30
+ payload.delete(:model)
31
+ payload.merge(preset: preset)
32
+ end
33
+
34
+ def format_document(document)
35
+ raise UnsupportedAttachmentError, document.mime_type
36
+ end
37
+
38
+ def supported_attachment?(attachment)
39
+ attachment.provider_file? || (%i[image text].include?(attachment.type) && super)
40
+ end
41
+
42
+ private
43
+
44
+ def default_max_output_tokens(model)
45
+ model.max_output_tokens || Anthropic::DEFAULT_MAX_OUTPUT_TOKENS if model.id.start_with?('anthropic/')
46
+ end
47
+
48
+ def preset_for(model_id)
49
+ return model_id if PRESETS.include?(model_id)
50
+
51
+ preset = SONAR_PRESETS[model_id]
52
+ if preset
53
+ RubyLLM.deprecator.warn(
54
+ "Perplexity retires Sonar on September 27, 2026. #{model_id} now runs the #{preset} " \
55
+ "Agent API preset; use model: \"#{preset}\" instead."
56
+ )
57
+ end
58
+ preset
59
+ end
60
+
61
+ def parse_citations(data, output, content)
62
+ citations = super
63
+ return citations if citations.any?
64
+
65
+ results = output.select { |item| item['type'] == 'search_results' }.flat_map { |item| Array(item['results']) }
66
+ parse_search_results(results)
67
+ end
68
+
69
+ def parse_usage(usage)
70
+ details = usage['input_tokens_details'] || {}
71
+ details = { 'cache_write_tokens' => details['cache_creation_input_tokens'] }.merge(details)
72
+
73
+ super(usage.merge('input_tokens_details' => details)).merge(reported_cost: usage.dig('cost', 'total_cost'))
74
+ end
75
+
76
+ def parse_server_tool_use(response)
77
+ response.dig('usage', 'tool_calls_details').to_h.to_h do |tool, details|
78
+ ["#{SERVER_TOOL_USAGE_NAMES.fetch(tool, tool)}_requests", details['invocation']]
79
+ end
80
+ end
81
+
82
+ # Perplexity sends each function call whole, without argument deltas,
83
+ # and announces its own web searches as function calls whose items
84
+ # finish as search_results. Only a finished function_call item is a
85
+ # call for the application to run.
86
+ def build_item_added_chunk(data)
87
+ data.dig('item', 'type') == 'function_call' ? chunk : super
88
+ end
89
+
90
+ def build_item_done_chunk(data)
91
+ item = data['item']
92
+ return super unless item['type'] == 'function_call'
93
+
94
+ chunk tool_calls: {
95
+ data['output_index'] => ToolCall.new(id: item['call_id'], name: item['name'],
96
+ arguments: +item['arguments'].to_s)
97
+ }
98
+ end
99
+
100
+ # Perplexity rejects its own search_results and fetch_url_results
101
+ # items as input, so a replayed turn keeps only messages, reasoning,
102
+ # and function calls.
103
+ def format_assistant_items(msg)
104
+ super.select do |item|
105
+ type = item[:type] || item['type']
106
+ type.nil? || Responses::Chat::CLIENT_OUTPUT_ITEM_TYPES.include?(type)
107
+ end
108
+ end
109
+
110
+ def supports_provider_file_references?
111
+ false
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -5,53 +5,12 @@ module RubyLLM
5
5
  module Perplexity
6
6
  # Perplexity Router's Chat Completions dialect.
7
7
  class Router < ChatCompletions
8
- REJECTED_OPTIONS = %i[seed logit_bias top_logprobs functions function_call modalities audio prediction
9
- web_search_options moderation verbosity].freeze
10
- DEFAULT_OPTIONS = { n: 1, logprobs: false, store: false, presence_penalty: 0, frequency_penalty: 0 }.freeze
11
- private_constant :REJECTED_OPTIONS, :DEFAULT_OPTIONS
12
-
13
8
  def completion_url
14
9
  @provider.router_url('chat/completions')
15
10
  end
16
11
 
17
- def render(...)
18
- super.tap do |payload|
19
- validate_router_options(payload)
20
- validate_router_tools(payload[:tools])
21
- end
22
- end
23
-
24
- def render_payload(messages, schema: nil, **options)
25
- if schema && schema[:strict] == false
26
- raise ArgumentError, 'Perplexity Router requires strict structured output'
27
- end
28
-
29
- super(messages, schema: schema&.merge(strict: true), **options)
30
- end
31
-
32
- def format_audio(audio)
33
- raise UnsupportedAttachmentError, audio.mime_type unless %w[mp3 wav].include?(audio.format)
34
-
35
- super
36
- end
37
-
38
- private
39
-
40
- def validate_router_options(payload)
41
- unsupported = payload.keys & REJECTED_OPTIONS
42
- unsupported.concat(DEFAULT_OPTIONS.keys.select do |key|
43
- payload.key?(key) && payload[key] != DEFAULT_OPTIONS[key]
44
- end)
45
- unsupported << :include_obfuscation if payload.dig(:stream_options, :include_obfuscation)
46
- return if unsupported.empty?
47
-
48
- raise ArgumentError, "Perplexity Router does not support these options: #{unsupported.join(', ')}"
49
- end
50
-
51
- def validate_router_tools(tools)
52
- return if Array(tools).all? { |tool| tool.dig(:function, :description).is_a?(String) }
53
-
54
- raise ArgumentError, 'Perplexity Router function tools require a description'
12
+ def render_payload(messages, schema: nil, **)
13
+ super(messages, schema: schema && { strict: true }.merge(schema), **)
55
14
  end
56
15
  end
57
16
  end
@@ -10,7 +10,6 @@ module RubyLLM
10
10
  end
11
11
 
12
12
  OPENAI_INLINE_FILE_LIMIT = 50 * 1024 * 1024
13
- OPENAI_FILE_UPLOAD_LIMIT = 512 * 1024 * 1024
14
13
  PROMPT_CACHE_OPTIONS = %i[key ttl mode retention].freeze
15
14
 
16
15
  module_function
@@ -69,11 +68,12 @@ module RubyLLM
69
68
  ),
70
69
  tool_calls: parse_pending_tool_calls(output, response: raw, finish_reason: finish_reason),
71
70
  server_tool_calls: server_tool_calls,
72
- raw_content: server_tool_calls.any? ? output : nil,
71
+ raw_content: keep_raw_output?(output, server_tool_calls) ? output : nil,
73
72
  model: data['model'],
74
73
  raw: raw,
75
74
  finish_reason: finish_reason,
76
- **parse_usage(data['usage'] || {})
75
+ **parse_usage(data['usage'] || {}),
76
+ server_tool_use: parse_server_tool_use(data)
77
77
  )
78
78
  end
79
79
 
@@ -105,6 +105,26 @@ module RubyLLM
105
105
  end
106
106
 
107
107
  CLIENT_OUTPUT_ITEM_TYPES = %w[message reasoning function_call].freeze
108
+ TOOL_SEARCH_ITEM_TYPES = %w[tool_search_call tool_search_output].freeze
109
+
110
+ PORTABLE_OUTPUT_ITEM_TYPES = (%w[message function_call] + TOOL_SEARCH_ITEM_TYPES).freeze
111
+
112
+ def keep_raw_output?(output, server_tool_calls)
113
+ server_tool_calls.any? || output.any? { |item| namespaced_call?(item) }
114
+ end
115
+
116
+ def namespaced_call?(item)
117
+ item['type'] == 'function_call' && !item['namespace'].nil?
118
+ end
119
+
120
+ def portable_raw_content(message)
121
+ items = message.raw_content
122
+ return unless items.is_a?(Array)
123
+ return unless items.any? { |item| TOOL_SEARCH_ITEM_TYPES.include?(item['type']) || namespaced_call?(item) }
124
+
125
+ kept = items.reject { |item| item['type'] == 'reasoning' }
126
+ kept if kept.all? { |item| PORTABLE_OUTPUT_ITEM_TYPES.include?(item['type']) }
127
+ end
108
128
 
109
129
  # Output items beyond text, reasoning, and function calls record
110
130
  # provider-executed tool steps (web_search_call, code_interpreter_call,
@@ -251,6 +271,14 @@ module RubyLLM
251
271
  }
252
272
  end
253
273
 
274
+ # Searches are billed by tool_usage, not by the web_search_call items
275
+ # in the output, which can each run several.
276
+ def parse_server_tool_use(response)
277
+ response['tool_usage'].to_h.filter_map do |tool, usage|
278
+ ["#{tool}_requests", usage['num_requests']] if usage['num_requests']
279
+ end.to_h
280
+ end
281
+
254
282
  def schema_format(schema)
255
283
  {
256
284
  type: 'json_schema',
@@ -260,32 +288,42 @@ module RubyLLM
260
288
  }
261
289
  end
262
290
 
263
- # System messages marked as cache boundaries, or carrying attachments,
264
- # ride along as input items, because the +instructions+ parameter is a
265
- # plain string and cannot carry a breakpoint marker or a file.
291
+ # The +instructions+ parameter is a plain string and cannot carry a
292
+ # breakpoint marker or a file. When any system message needs one, every
293
+ # system message rides along as an input item, so their order holds.
266
294
  def format_instructions(messages, caching: nil)
267
- instructions = messages.select { |msg| msg.role == :system && !system_input_item?(msg, caching:) }
268
- .map { |msg| msg.content.to_s }
295
+ system = messages.select { |msg| msg.role == :system }
296
+ return if system.empty? || system_input_items?(system, caching:)
269
297
 
270
- instructions.empty? ? nil : instructions.join("\n\n")
298
+ system.map { |msg| msg.content.to_s }.join("\n\n")
271
299
  end
272
300
 
273
301
  def format_input(messages, caching: nil)
274
- system_items = []
275
- messages.each_with_object([]) do |message, input|
276
- next if message.role == :system && !system_input_item?(message, caching:)
302
+ system, conversation = messages.partition { |msg| msg.role == :system }
303
+ system_items = if system_input_items?(system, caching:)
304
+ system.flat_map { |msg| [format_item(msg, caching:)].flatten(1) }
305
+ else
306
+ []
307
+ end
277
308
 
309
+ system_items + format_conversation(conversation, caching:)
310
+ end
311
+
312
+ def format_conversation(messages, caching: nil)
313
+ messages.each_with_object([]) do |message, input|
278
314
  raw = message.raw_content
279
315
  if raw.is_a?(Hash) && raw['object'] == 'response.compaction'
280
- input.replace(system_items + raw.fetch('output'))
316
+ input.replace(raw.fetch('output'))
281
317
  else
282
- items = [format_item(message, caching:)].flatten(1)
283
- system_items.concat(items) if message.role == :system
284
- input.concat(items)
318
+ input.concat([format_item(message, caching:)].flatten(1))
285
319
  end
286
320
  end
287
321
  end
288
322
 
323
+ def system_input_items?(messages, caching: nil)
324
+ messages.any? { |msg| system_input_item?(msg, caching:) }
325
+ end
326
+
289
327
  def system_input_item?(msg, caching: nil)
290
328
  msg.role == :system && ((caching != false && msg.cache_until_here?) || msg.attachments.any?)
291
329
  end
@@ -333,7 +371,7 @@ module RubyLLM
333
371
 
334
372
  if msg.attachments.any?
335
373
  parts = [{ type: 'input_text', text: "Attachments from tool call #{msg.tool_call_id}:" }]
336
- parts.concat(Media.format_content(nil, msg.attachments))
374
+ parts.concat(format_content(nil, msg.attachments))
337
375
  items << { role: 'user', content: parts }
338
376
  end
339
377
 
@@ -346,7 +384,7 @@ module RubyLLM
346
384
  return msg.raw_content if msg.raw_content
347
385
 
348
386
  items = []
349
- items << format_reasoning_item(msg.thinking) if msg.thinking&.signature
387
+ items << format_reasoning_item(msg.thinking) if own_signature(msg)
350
388
  items << { role: 'assistant', content: format_output_content(msg) } unless empty_content?(msg.content)
351
389
  items.concat(format_function_call_items(msg.tool_calls)) if msg.tool_call?
352
390
  items
@@ -449,10 +487,6 @@ module RubyLLM
449
487
  OPENAI_INLINE_FILE_LIMIT
450
488
  end
451
489
 
452
- def provider_file_upload_limit
453
- OPENAI_FILE_UPLOAD_LIMIT
454
- end
455
-
456
490
  def provider_file_attachable?(attachment)
457
491
  attachment.pdf? || attachment.document?
458
492
  end
@@ -7,6 +7,10 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment)
11
+ attachment.provider_file? || %i[image pdf document text].include?(attachment.type)
12
+ end
13
+
10
14
  def format_content(content, attachments = [])
11
15
  return content if attachments.empty?
12
16
 
@@ -17,6 +21,8 @@ module RubyLLM
17
21
  end
18
22
 
19
23
  def format_attachment(attachment)
24
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
25
+
20
26
  return format_provider_file(attachment) if attachment.provider_file?
21
27
 
22
28
  case attachment.type
@@ -26,16 +32,14 @@ module RubyLLM
26
32
  format_document(attachment)
27
33
  when :text
28
34
  { type: 'input_text', text: attachment.for_llm }
29
- else
30
- raise UnsupportedAttachmentError, attachment.mime_type
31
35
  end
32
36
  end
33
37
 
34
- def format_image(image)
35
- {
36
- type: 'input_image',
37
- image_url: image.url? ? image.source.to_s : image.for_llm
38
- }
38
+ def format_image(image, image_url: image.url_or_data_uri, original_detail: @provider&.original_image_detail?)
39
+ part = { type: 'input_image', image_url: }
40
+ return part unless image.resolution
41
+
42
+ part.merge(detail: ChatCompletions::Media.image_detail(image.resolution, original_detail:))
39
43
  end
40
44
 
41
45
  # The Responses API extracts text from documents, presentations, and