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
@@ -46,6 +46,13 @@ module RubyLLM
46
46
  keys.map { |key| ":#{key}" }.join(', ')
47
47
  end
48
48
 
49
+ # extra_content carries Gemini's thought signatures on Google's
50
+ # OpenAI-compatible endpoints, and Mistral refuses any field it does
51
+ # not define.
52
+ def format_tool_calls(tool_calls)
53
+ super&.map { |call| call.except(:extra_content) }
54
+ end
55
+
49
56
  def build_tool_choice(tool_choice)
50
57
  return 'any' if tool_choice == :required
51
58
 
@@ -70,7 +77,7 @@ module RubyLLM
70
77
  formatted_content = super
71
78
  return formatted_content unless msg.role == :assistant && msg.thinking
72
79
 
73
- content_blocks = build_thinking_blocks(msg.thinking)
80
+ content_blocks = build_thinking_blocks(msg)
74
81
  append_formatted_content(content_blocks, formatted_content)
75
82
 
76
83
  content_blocks
@@ -80,17 +87,14 @@ module RubyLLM
80
87
  {}
81
88
  end
82
89
 
83
- def build_thinking_blocks(thinking)
84
- return [] unless thinking
85
-
86
- if thinking.text
87
- [{
88
- type: 'thinking',
89
- thinking: [{ type: 'text', text: thinking.text }],
90
- signature: thinking.signature
91
- }.compact]
92
- elsif thinking.signature
93
- [{ type: 'thinking', signature: thinking.signature }]
90
+ def build_thinking_blocks(msg)
91
+ text = msg.thinking&.text
92
+ signature = own_signature(msg)
93
+
94
+ if text
95
+ [{ type: 'thinking', thinking: [{ type: 'text', text: }], signature: }.compact]
96
+ elsif signature
97
+ [{ type: 'thinking', signature: }]
94
98
  else
95
99
  []
96
100
  end
@@ -7,8 +7,14 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment)
11
+ %i[image audio pdf document text].include?(attachment.type)
12
+ end
13
+
10
14
  def format_content(content, attachments = [])
11
15
  Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
16
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
17
+
12
18
  case attachment.type
13
19
  when :image
14
20
  format_image(attachment)
@@ -18,8 +24,6 @@ module RubyLLM
18
24
  format_document(attachment)
19
25
  when :text
20
26
  Protocols::ChatCompletions::Media.format_text_file(attachment)
21
- else
22
- raise UnsupportedAttachmentError, attachment.mime_type
23
27
  end
24
28
  end
25
29
  end
@@ -27,14 +31,14 @@ module RubyLLM
27
31
  def format_image(image)
28
32
  {
29
33
  type: 'image_url',
30
- image_url: image.url? ? image.source.to_s : image.for_llm
34
+ image_url: image.url_or_data_uri
31
35
  }
32
36
  end
33
37
 
34
38
  def format_document(document)
35
39
  {
36
40
  type: 'document_url',
37
- document_url: document.url? ? document.source.to_s : document.for_llm
41
+ document_url: document.url_or_data_uri
38
42
  }
39
43
  end
40
44
  end
@@ -22,7 +22,7 @@ module RubyLLM
22
22
  end
23
23
 
24
24
  def ocr_document_part(attachment)
25
- reference = attachment.url? ? attachment.source.to_s : ocr_data_uri(attachment)
25
+ reference = attachment.url_or_data_uri
26
26
 
27
27
  if attachment.image?
28
28
  { type: 'image_url', image_url: reference }
@@ -31,10 +31,6 @@ module RubyLLM
31
31
  end
32
32
  end
33
33
 
34
- def ocr_data_uri(attachment)
35
- "data:#{attachment.mime_type};base64,#{attachment.encoded}"
36
- end
37
-
38
34
  def parse_ocr_response(response, model:)
39
35
  data = response.body
40
36
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Ollama
6
+ # Ollama's dialect of the System One API, which accepts images for multimodal decision models.
7
+ module Judgments
8
+ module_function
9
+
10
+ def judgment_url
11
+ 'systemone'
12
+ end
13
+
14
+ def render_judgment_payload(input, questions:, model:, with: [], provider_options: {})
15
+ images = with.map do |attachment|
16
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
17
+
18
+ attachment.encoded
19
+ end
20
+ payload = super(input, questions:, model:, provider_options:)
21
+ images.empty? ? payload : payload.merge(images:)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -7,8 +7,14 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
+ def supported_attachment?(attachment)
11
+ %i[image audio text].include?(attachment.type)
12
+ end
13
+
10
14
  def format_content(content, attachments = [])
11
15
  Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
16
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
17
+
12
18
  case attachment.type
13
19
  when :image
14
20
  format_image(attachment)
@@ -16,8 +22,6 @@ module RubyLLM
16
22
  Protocols::ChatCompletions::Media.format_audio(attachment)
17
23
  when :text
18
24
  Protocols::ChatCompletions::Media.format_text_file(attachment)
19
- else
20
- raise UnsupportedAttachmentError, attachment.mime_type
21
25
  end
22
26
  end
23
27
  end
@@ -59,6 +59,7 @@ module RubyLLM
59
59
 
60
60
  def build_capabilities(reported)
61
61
  return [] if reported.include?('embedding')
62
+ return %w[judgment] if reported.include?('decision')
62
63
 
63
64
  derived = CAPABILITY_MAP.filter_map { |native, capability| capability if reported.include?(native) }
64
65
  model_capabilities + derived
@@ -66,6 +67,7 @@ module RubyLLM
66
67
 
67
68
  def build_modalities(reported)
68
69
  return { input: %w[text], output: %w[embeddings] } if reported.include?('embedding')
70
+ return { input: %w[text], output: %w[judgment] } if reported.include?('decision')
69
71
 
70
72
  input = %w[text]
71
73
  input << 'image' if reported.include?('vision')
@@ -11,12 +11,23 @@ module RubyLLM
11
11
  include Ollama::Models
12
12
  end
13
13
 
14
+ # Ollama's dialect of the System One API for decision models such as Clef.
15
+ class SystemOne < Protocols::SystemOne
16
+ include Ollama::Judgments
17
+ end
18
+
14
19
  protocol :chat_completions, ChatCompletions
20
+ protocol :system_one, SystemOne
15
21
 
16
22
  def api_base
17
23
  @config.ollama_api_base
18
24
  end
19
25
 
26
+ def resolve_protocol(name, model, operation: nil, **options)
27
+ name ||= :system_one if operation == :judge
28
+ super
29
+ end
30
+
20
31
  def headers
21
32
  return {} unless @config.ollama_api_key
22
33
 
@@ -46,13 +46,15 @@ module RubyLLM
46
46
  gpt-4o-transcribe-diarize
47
47
  whisper-1
48
48
  ].freeze
49
+ JUDGMENT_MODELS = %w[gpt-6-luna].freeze
49
50
  EXPLICIT_CAPABILITIES = {
50
51
  'function_calling' => (CHAT_MODELS + CODEX_MODELS).freeze,
51
52
  'structured_output' => (CHAT_MODELS + CODEX_MODELS + SEARCH_MODELS).freeze,
52
53
  'vision' => (CHAT_MODELS + CODEX_MODELS + DEEP_RESEARCH_MODELS + MODERATION_MODELS).freeze,
53
54
  'reasoning' => (CODEX_MODELS + DEEP_RESEARCH_MODELS).freeze,
54
55
  'transcription' => TRANSCRIPTION_MODELS,
55
- 'citations' => SEARCH_MODELS
56
+ 'citations' => SEARCH_MODELS,
57
+ 'judgment' => JUDGMENT_MODELS
56
58
  }.freeze
57
59
 
58
60
  def self.augment(capabilities, model_id:, **)
@@ -9,14 +9,24 @@ module RubyLLM
9
9
  protocol :embeddings, Protocols::ChatCompletions,
10
10
  batches: Protocols::ChatCompletions::EmbeddingBatches
11
11
  protocol :files, Protocols::OpenAI::Files
12
+ protocol :decisions, Protocols::OpenAI::Decisions
12
13
 
13
14
  RATE_LIMIT_RESET_HEADERS = %w[x-ratelimit-reset-requests x-ratelimit-reset-tokens].freeze
14
15
  RESET_DURATION_UNITS = { 'h' => 3600, 'm' => 60, 's' => 1, 'ms' => 0.001 }.freeze
15
16
 
17
+ def original_image_detail? # :nodoc:
18
+ true
19
+ end
20
+
16
21
  def api_base
17
22
  @config.openai_api_base || 'https://api.openai.com/v1'
18
23
  end
19
24
 
25
+ def resolve_protocol(name, model, operation: nil, **options)
26
+ name ||= :decisions if operation == :judge
27
+ super
28
+ end
29
+
20
30
  # Audio, realtime, and dedicated search models only exist on Chat Completions.
21
31
  def protocol_for(model, **)
22
32
  return protocols[:chat_completions] if Capabilities::SEARCH_MODELS.include?(model.id)
@@ -32,6 +42,10 @@ module RubyLLM
32
42
  }.compact
33
43
  end
34
44
 
45
+ def account_identity
46
+ account_digest(api_base, @config.openai_api_key, @config.openai_organization_id, @config.openai_project_id)
47
+ end
48
+
35
49
  # OpenAI reports when each rate limit resets in its own headers,
36
50
  # as durations like "6m0s", "7.66s" or "76ms".
37
51
  def retry_delay(response)
@@ -6,7 +6,6 @@ module RubyLLM
6
6
  # Chat methods of the OpenRouter API integration
7
7
  module Chat
8
8
  OPENROUTER_INLINE_FILE_THRESHOLD = 50 * 1024 * 1024
9
- OPENROUTER_FILE_UPLOAD_LIMIT = 100 * 1024 * 1024
10
9
  CACHE_CONTROL_TYPE = 'ephemeral'
11
10
  PROMPT_CACHE_OPTIONS = %i[ttl].freeze
12
11
  COMPACTION_PLUGIN_ID = 'context-compression'
@@ -35,6 +34,10 @@ module RubyLLM
35
34
  end
36
35
  end
37
36
 
37
+ def supported_attachment?(attachment)
38
+ OpenRouter::Media.supported_attachment?(attachment)
39
+ end
40
+
38
41
  def format_content(content, attachments = [])
39
42
  OpenRouter::Media.format_content(content, attachments)
40
43
  end
@@ -86,32 +89,28 @@ module RubyLLM
86
89
  return {} unless msg.role == :assistant
87
90
  return { reasoning_details: msg.raw_reasoning } if msg.raw_reasoning.is_a?(Array)
88
91
 
89
- thinking = msg.thinking
90
- return {} unless thinking
92
+ return {} unless msg.thinking && producer_slug(msg) == @provider.slug
93
+
94
+ detail = reasoning_detail(msg.thinking)
95
+ detail ? { reasoning_details: [detail] } : {}
96
+ end
91
97
 
92
- details = []
98
+ def reasoning_detail(thinking)
93
99
  if thinking.text
94
- details << {
95
- type: 'reasoning.text',
96
- text: thinking.text,
97
- signature: thinking.signature
98
- }.compact
100
+ { type: 'reasoning.text', text: thinking.text, signature: thinking.signature }.compact
99
101
  elsif thinking.signature
100
- details << {
101
- type: 'reasoning.encrypted',
102
- data: thinking.signature
103
- }
102
+ { type: 'reasoning.encrypted', data: thinking.signature }
104
103
  end
105
-
106
- details.empty? ? {} : { reasoning_details: details }
107
104
  end
108
105
 
109
106
  def format_message_content(msg, caching: nil)
110
107
  content = super
111
- caching != false && msg.cache_until_here? ? inject_cache_control(content, caching:) : content
108
+ return content unless caching != false && msg.cache_until_here?
109
+
110
+ inject_cache_control(content, caching:, ttl: msg.cache_ttl)
112
111
  end
113
112
 
114
- def inject_cache_control(content, caching: nil)
113
+ def inject_cache_control(content, caching: nil, ttl: nil)
115
114
  blocks = content.is_a?(Array) ? content.dup : [{ type: 'text', text: content }]
116
115
  return blocks if blocks.empty?
117
116
 
@@ -119,15 +118,16 @@ module RubyLLM
119
118
  return blocks unless last.is_a?(Hash)
120
119
  return blocks if last[:cache_control] || last['cache_control']
121
120
 
122
- blocks[-1] = last.merge(cache_control: prompt_cache_control(caching))
121
+ blocks[-1] = last.merge(cache_control: prompt_cache_control(caching, ttl:))
123
122
  blocks
124
123
  end
125
124
 
126
- def prompt_cache_control(caching = nil)
125
+ def prompt_cache_control(caching = nil, ttl: nil)
127
126
  options = prompt_cache_options(caching)
127
+ ttl ||= options[:ttl]
128
128
 
129
129
  { type: CACHE_CONTROL_TYPE }.tap do |control|
130
- control[:ttl] = options[:ttl] if options[:ttl]
130
+ control[:ttl] = ttl if ttl
131
131
  end
132
132
  end
133
133
 
@@ -166,10 +166,6 @@ module RubyLLM
166
166
  OPENROUTER_INLINE_FILE_THRESHOLD
167
167
  end
168
168
 
169
- def provider_file_upload_limit
170
- OPENROUTER_FILE_UPLOAD_LIMIT
171
- end
172
-
173
169
  def provider_file_attachable?(attachment)
174
170
  attachment.pdf?
175
171
  end
@@ -39,6 +39,8 @@ module RubyLLM
39
39
 
40
40
  if (type = EMBEDDING_MEDIA_TYPES[attachment.type])
41
41
  { type: type.to_s, type => { data: attachment.for_llm, format: attachment.format } }
42
+ elsif attachment.type == :image
43
+ Protocols::ChatCompletions::Media.format_image(attachment)
42
44
  else
43
45
  Protocols::ChatCompletions::Media.format_attachment(
44
46
  attachment, document_attachments: :none, image_attachments: true, audio_attachments: false
@@ -8,6 +8,10 @@ module RubyLLM
8
8
  module Media
9
9
  module_function
10
10
 
11
+ def supported_attachment?(attachment)
12
+ attachment.video? || Protocols::ChatCompletions::Media.supported_attachment?(attachment)
13
+ end
14
+
11
15
  def format_content(content, attachments = [])
12
16
  Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
13
17
  if attachment.type == :video
@@ -24,7 +28,7 @@ module RubyLLM
24
28
  {
25
29
  type: 'video_url',
26
30
  video_url: {
27
- url: video.url? ? video.source.to_s : video.for_llm
31
+ url: video.url_or_data_uri
28
32
  }
29
33
  }
30
34
  end
@@ -17,6 +17,7 @@ module RubyLLM
17
17
  role: :assistant,
18
18
  model: data['model'],
19
19
  content: delta['content'],
20
+ citations: extract_chunk_citations(delta, data),
20
21
  thinking: Thinking.build(
21
22
  text: extract_thinking_text(delta),
22
23
  signature: extract_thinking_signature(delta)
@@ -28,6 +29,7 @@ module RubyLLM
28
29
  cache_read_tokens: cache_read_tokens(usage),
29
30
  cache_write_tokens: cache_write_tokens(usage),
30
31
  thinking_tokens: thinking_tokens(usage),
32
+ server_tool_use: server_tool_use(usage),
31
33
  reported_cost: reported_cost(usage),
32
34
  finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason'))
33
35
  )
@@ -47,7 +49,8 @@ module RubyLLM
47
49
 
48
50
  detail.each do |key, value|
49
51
  if value.is_a?(String) && target[key].is_a?(String) && ACCUMULATED_REASONING_KEYS.include?(key)
50
- target[key] += value
52
+ target[key] = +target[key]
53
+ target[key] << value
51
54
  elsif target[key].nil?
52
55
  target[key] = value
53
56
  end
@@ -20,11 +20,16 @@ module RubyLLM
20
20
  payload.merge(provider_options)
21
21
  end
22
22
 
23
- def parse_video_job(response, model:)
23
+ def parse_video_job(response, model:, **request)
24
24
  id = response.body['id']
25
25
  raise Error.new('OpenRouter did not return a video job', response:) unless id
26
26
 
27
- VideoJob.new(id: id, protocol: self, model: model, status: job_status(response.body), raw: response.body)
27
+ VideoJob.new(id: id, protocol: self, model: model, status: job_status(response.body), raw: response.body,
28
+ **request)
29
+ end
30
+
31
+ def video_request_settings(request)
32
+ { duration: request[:duration], resolution: request[:resolution] || request[:size] }
28
33
  end
29
34
 
30
35
  def video_job_url(job)
@@ -34,9 +39,9 @@ module RubyLLM
34
39
  def parse_video_job_status(response, job:) # rubocop:disable Lint/UnusedMethodArgument
35
40
  body = response.body
36
41
  status = job_status(body)
37
- state = { status: status, raw: body }
42
+ state = { status: status, raw: body, reported_cost: reported_cost(body['usage'] || {}) }
38
43
  state[:error] = body['error'] || body['status'] if status == :failed
39
- state
44
+ state.compact
40
45
  end
41
46
 
42
47
  def download_video(job)
@@ -71,7 +76,7 @@ module RubyLLM
71
76
 
72
77
  def frame_images(attachments)
73
78
  attachments.zip(FRAME_TYPES).map do |attachment, frame_type|
74
- url = attachment.url? ? attachment.source.to_s : attachment.for_llm
79
+ url = attachment.url_or_data_uri
75
80
  { type: 'image_url', image_url: { url: url }, frame_type: frame_type }
76
81
  end
77
82
  end
@@ -56,6 +56,10 @@ module RubyLLM
56
56
  }
57
57
  end
58
58
 
59
+ def account_identity
60
+ account_digest(api_base, @config.openrouter_api_key)
61
+ end
62
+
59
63
  def parse_error(response)
60
64
  body = parse_error_body(response)
61
65
  return unless body
@@ -11,6 +11,10 @@ module RubyLLM
11
11
  role.to_s
12
12
  end
13
13
 
14
+ def supported_attachment?(attachment)
15
+ Perplexity::Media.supported_attachment?(attachment)
16
+ end
17
+
14
18
  def format_content(content, attachments = [])
15
19
  Perplexity::Media.format_content(content, attachments)
16
20
  end
@@ -7,10 +7,14 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
- SUPPORTED_DOCUMENT_EXTENSIONS = %w[pdf doc docx txt rtf].freeze
10
+ def supported_attachment?(attachment)
11
+ %i[image pdf document text].include?(attachment.type)
12
+ end
11
13
 
12
14
  def format_content(content, attachments = [])
13
15
  Protocols::ChatCompletions::Media.format_parts(content, attachments) do |attachment|
16
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
17
+
14
18
  case attachment.type
15
19
  when :image
16
20
  Protocols::ChatCompletions::Media.format_image(attachment)
@@ -18,15 +22,11 @@ module RubyLLM
18
22
  format_document(attachment)
19
23
  when :text
20
24
  Protocols::ChatCompletions::Media.format_text_file(attachment)
21
- else
22
- raise UnsupportedAttachmentError, attachment.mime_type
23
25
  end
24
26
  end
25
27
  end
26
28
 
27
29
  def format_document(attachment)
28
- raise UnsupportedAttachmentError, attachment.mime_type unless supported_file?(attachment)
29
-
30
30
  {
31
31
  type: 'file_url',
32
32
  file_url: {
@@ -34,12 +34,6 @@ module RubyLLM
34
34
  }
35
35
  }
36
36
  end
37
-
38
- def supported_file?(attachment)
39
- return true if attachment.pdf?
40
-
41
- SUPPORTED_DOCUMENT_EXTENSIONS.include?(attachment.extension)
42
- end
43
37
  end
44
38
  end
45
39
  end
@@ -11,12 +11,16 @@ module RubyLLM
11
11
  sonar-reasoning-pro
12
12
  sonar-deep-research
13
13
  ].freeze
14
+ PRESET_MODEL_IDS = Protocols::Perplexity::Agent::PRESETS
14
15
  EMBEDDING_MODEL_IDS = %w[
15
16
  pplx-embed-v1-0.6b
16
17
  pplx-embed-v1-4b
17
18
  ].freeze
18
- STATIC_MODEL_IDS = (SEARCH_MODEL_IDS + EMBEDDING_MODEL_IDS).freeze
19
- STATIC_MODEL_DATA = {
19
+ STATIC_MODEL_IDS = (SEARCH_MODEL_IDS + PRESET_MODEL_IDS + EMBEDDING_MODEL_IDS).freeze
20
+ PRESET_MODEL_DATA = PRESET_MODEL_IDS.to_h do |id|
21
+ [id, { capabilities: %w[streaming structured_output citations function_calling] }]
22
+ end
23
+ STATIC_MODEL_DATA = PRESET_MODEL_DATA.merge(
20
24
  'sonar' => {
21
25
  context_window: 128_000, input_price: 1.0, output_price: 1.0,
22
26
  capabilities: %w[streaming structured_output citations]
@@ -35,7 +39,7 @@ module RubyLLM
35
39
  },
36
40
  'pplx-embed-v1-0.6b' => { context_window: 32_768, input_price: 0.004 },
37
41
  'pplx-embed-v1-4b' => { context_window: 32_768, input_price: 0.03 }
38
- }.freeze
42
+ ).freeze
39
43
 
40
44
  def models_url
41
45
  'v1/models'
@@ -49,7 +53,7 @@ module RubyLLM
49
53
  end
50
54
 
51
55
  # The models endpoint lists the multi-model catalog but not the search
52
- # or embedding models, so those ride along statically.
56
+ # models, presets, or embedding models, so those ride along statically.
53
57
  def parse_list_models_response(response, slug)
54
58
  listed = Array(response.body['data']).map do |model_data|
55
59
  create_model_info(model_data['id'], slug, pricing: endpoint_pricing(model_data['pricing']))
@@ -14,11 +14,22 @@ module RubyLLM
14
14
  protocol :chat_completions, ChatCompletions
15
15
  protocol :router_chat_completions, Protocols::Perplexity::Router
16
16
  protocol :files, Protocols::Perplexity::Files
17
+ protocol :agent_responses, Protocols::Perplexity::Agent
17
18
 
18
19
  def api_base
19
20
  @config.perplexity_api_base || 'https://api.perplexity.ai'
20
21
  end
21
22
 
23
+ # Chat runs on the Agent API. Embeddings and the model listing stay on
24
+ # the endpoints the Chat Completions dialect knows.
25
+ def protocol_for(model, operation: nil, **)
26
+ operation ? super : protocols[:agent_responses]
27
+ end
28
+
29
+ def agent_url # :nodoc:
30
+ "#{api_base.delete_suffix('/').delete_suffix('/v1')}/v1/agent"
31
+ end
32
+
22
33
  def router_url(operation) # :nodoc:
23
34
  "#{api_base.delete_suffix('/').delete_suffix('/router/v1')}/router/v1/#{operation}"
24
35
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Connects TypeSafe's judgment models to RubyLLM.
6
+ class TypeSafe < Provider
7
+ protocol :system_one, Protocols::SystemOne
8
+
9
+ def api_base
10
+ @config.typesafe_api_base || 'https://api.typesafe.ai'
11
+ end
12
+
13
+ def headers
14
+ { 'Authorization' => "Bearer #{@config.typesafe_api_key}" }
15
+ end
16
+
17
+ def parse_error(response)
18
+ protocols.fetch(:system_one).new(self).parse_error_response(response) || super
19
+ end
20
+
21
+ class << self
22
+ def configuration_options
23
+ %i[typesafe_api_key typesafe_api_base]
24
+ end
25
+
26
+ def configuration_requirements
27
+ %i[typesafe_api_key]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'stringio'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class VertexAI
9
+ # Google credentials shared by every Vertex AI provider in the process.
10
+ # googleauth keeps the access token on the credentials object and
11
+ # refreshes it a minute before it expires, so sharing the object shares
12
+ # the token.
13
+ class Credentials # :nodoc:
14
+ CACHE = Support::ProcessCache.new
15
+
16
+ attr_reader :authorizer
17
+
18
+ def self.for(config)
19
+ key = config.vertexai_service_account_key
20
+ CACHE.fetch([source(key), SCOPES]) { new(key) }
21
+ end
22
+
23
+ def self.cache
24
+ CACHE
25
+ end
26
+
27
+ def self.source(key)
28
+ return Digest::SHA256.hexdigest(key) if key
29
+
30
+ [:application_default, ENV.fetch('GOOGLE_APPLICATION_CREDENTIALS', nil)]
31
+ end
32
+ private_class_method :source
33
+
34
+ def initialize(service_account_key)
35
+ @authorizer = build(service_account_key)
36
+ @lock = Mutex.new
37
+ end
38
+
39
+ # One caller fetches an expiring token while the others wait for it.
40
+ def headers
41
+ @lock.synchronize { @authorizer.apply({}) }
42
+ end
43
+
44
+ private
45
+
46
+ def build(service_account_key)
47
+ require 'googleauth'
48
+ if service_account_key
49
+ ::Google::Auth::ServiceAccountCredentials.make_creds(
50
+ json_key_io: StringIO.new(service_account_key),
51
+ scope: SCOPES
52
+ )
53
+ else
54
+ ::Google::Auth.get_application_default(SCOPES)
55
+ end
56
+ rescue LoadError
57
+ raise Error,
58
+ 'The googleauth gem ~> 1.15 is required for Vertex AI. Please add it to your Gemfile: gem "googleauth"'
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end