ruby_llm 2.0.0.rc4 → 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 (286) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -9
  3. data/lib/generators/ruby_llm/generator_helpers.rb +72 -7
  4. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  5. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +2 -1
  6. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -8
  7. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  8. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  11. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  12. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  13. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  14. data/lib/ruby_llm/accounting/usage.rb +86 -49
  15. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  16. data/lib/ruby_llm/active_record/attachment_helpers.rb +131 -28
  17. data/lib/ruby_llm/active_record/batch.rb +1 -1
  18. data/lib/ruby_llm/active_record/chat_methods.rb +145 -46
  19. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  20. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  21. data/lib/ruby_llm/active_record/model.rb +1 -1
  22. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  23. data/lib/ruby_llm/active_record/record.rb +19 -0
  24. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  25. data/lib/ruby_llm/active_record/usage.rb +53 -3
  26. data/lib/ruby_llm/agent.rb +213 -48
  27. data/lib/ruby_llm/aliases.json +66 -8
  28. data/lib/ruby_llm/attachment.rb +54 -10
  29. data/lib/ruby_llm/batch.rb +26 -3
  30. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  31. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  32. data/lib/ruby_llm/chat.rb +502 -96
  33. data/lib/ruby_llm/choice.rb +38 -0
  34. data/lib/ruby_llm/configuration.rb +36 -11
  35. data/lib/ruby_llm/context.rb +45 -32
  36. data/lib/ruby_llm/cost.rb +42 -1
  37. data/lib/ruby_llm/embedding.rb +9 -2
  38. data/lib/ruby_llm/error.rb +35 -3
  39. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  40. data/lib/ruby_llm/evaluation/case.rb +52 -0
  41. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  42. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  43. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  44. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  45. data/lib/ruby_llm/evaluation/report.rb +103 -0
  46. data/lib/ruby_llm/evaluation/result.rb +75 -0
  47. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  48. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  49. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  50. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  51. data/lib/ruby_llm/evaluation.rb +289 -0
  52. data/lib/ruby_llm/image.rb +12 -4
  53. data/lib/ruby_llm/judge/builder.rb +51 -0
  54. data/lib/ruby_llm/judge/data.rb +43 -0
  55. data/lib/ruby_llm/judge/question.rb +101 -0
  56. data/lib/ruby_llm/judge.rb +179 -0
  57. data/lib/ruby_llm/judgment.rb +125 -0
  58. data/lib/ruby_llm/mcp/apps.rb +25 -0
  59. data/lib/ruby_llm/mcp/client.rb +213 -0
  60. data/lib/ruby_llm/mcp/collection.rb +56 -0
  61. data/lib/ruby_llm/mcp/content.rb +44 -0
  62. data/lib/ruby_llm/mcp/error.rb +27 -0
  63. data/lib/ruby_llm/mcp/http.rb +379 -0
  64. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  65. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  66. data/lib/ruby_llm/mcp/listener.rb +168 -0
  67. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  68. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  69. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  70. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  71. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  72. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  73. data/lib/ruby_llm/mcp/resource.rb +84 -0
  74. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  75. data/lib/ruby_llm/mcp/result.rb +76 -0
  76. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  77. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  78. data/lib/ruby_llm/mcp/task.rb +151 -0
  79. data/lib/ruby_llm/mcp/tool.rb +130 -0
  80. data/lib/ruby_llm/mcp.rb +1101 -0
  81. data/lib/ruby_llm/message.rb +55 -6
  82. data/lib/ruby_llm/model.rb +2 -1
  83. data/lib/ruby_llm/models/aliases.rb +5 -2
  84. data/lib/ruby_llm/models/lookup.rb +80 -0
  85. data/lib/ruby_llm/models/registry.rb +2 -2
  86. data/lib/ruby_llm/models/schema.rb +1 -1
  87. data/lib/ruby_llm/models.json +23082 -10158
  88. data/lib/ruby_llm/models.rb +20 -69
  89. data/lib/ruby_llm/moderation.rb +7 -2
  90. data/lib/ruby_llm/ocr.rb +7 -2
  91. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  92. data/lib/ruby_llm/open_telemetry.rb +82 -0
  93. data/lib/ruby_llm/probability.rb +30 -0
  94. data/lib/ruby_llm/progress.rb +44 -0
  95. data/lib/ruby_llm/prompt/context.rb +55 -0
  96. data/lib/ruby_llm/prompt.rb +1 -1
  97. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  98. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  99. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  100. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  101. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  102. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  103. data/lib/ruby_llm/protocol.rb +217 -42
  104. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  105. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  106. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  107. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  108. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  109. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  110. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  111. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  112. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  113. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  114. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  115. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  116. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  117. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  118. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  119. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  120. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  121. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  122. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  123. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  124. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  125. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  126. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  127. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  128. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  129. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  130. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  131. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  132. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  133. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  134. data/lib/ruby_llm/protocols/converse.rb +3 -5
  135. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  137. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  138. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  139. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  140. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  141. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  142. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  143. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  144. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  145. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  146. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  147. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  148. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  149. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  150. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  151. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  152. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  153. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  154. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  155. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  156. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  157. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  158. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  159. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  160. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  161. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  162. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  163. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  164. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  166. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  167. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  168. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  169. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  170. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  171. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  172. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  173. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  174. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  175. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  176. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  177. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  178. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  179. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  180. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  181. data/lib/ruby_llm/protocols/responses.rb +1 -0
  182. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  183. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  184. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  185. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  186. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  187. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  188. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  189. data/lib/ruby_llm/protocols/vertexai/research.rb +17 -49
  190. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  191. data/lib/ruby_llm/provider.rb +91 -19
  192. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  193. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  194. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  195. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  196. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  197. data/lib/ruby_llm/providers/azure.rb +24 -4
  198. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  200. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  201. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  202. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  203. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  204. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  205. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  206. data/lib/ruby_llm/providers/gemini.rb +4 -0
  207. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  208. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  209. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  210. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  211. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  212. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  213. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  214. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  215. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  216. data/lib/ruby_llm/providers/ollama.rb +11 -0
  217. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  218. data/lib/ruby_llm/providers/openai.rb +14 -0
  219. data/lib/ruby_llm/providers/openrouter/chat.rb +24 -24
  220. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  221. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  222. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  223. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  224. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  226. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  227. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  228. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  229. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  230. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  231. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  232. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  233. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  234. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  235. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  236. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  237. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  238. data/lib/ruby_llm/providers/xai.rb +4 -0
  239. data/lib/ruby_llm/railtie.rb +11 -0
  240. data/lib/ruby_llm/request_shape/part.rb +117 -0
  241. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  242. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  243. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  244. data/lib/ruby_llm/request_shape.rb +160 -0
  245. data/lib/ruby_llm/rerank.rb +9 -2
  246. data/lib/ruby_llm/research_job.rb +23 -7
  247. data/lib/ruby_llm/score.rb +42 -0
  248. data/lib/ruby_llm/search_results.rb +2 -2
  249. data/lib/ruby_llm/server_tool_call.rb +12 -4
  250. data/lib/ruby_llm/speech.rb +8 -1
  251. data/lib/ruby_llm/support/cancellation.rb +39 -0
  252. data/lib/ruby_llm/support/inspectable.rb +3 -2
  253. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  254. data/lib/ruby_llm/support/process_cache.rb +60 -0
  255. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  256. data/lib/ruby_llm/thinking.rb +2 -1
  257. data/lib/ruby_llm/tokens.rb +57 -14
  258. data/lib/ruby_llm/tool.rb +50 -3
  259. data/lib/ruby_llm/tool_call.rb +17 -2
  260. data/lib/ruby_llm/tools/{server_tools.rb → provider_tools.rb} +7 -7
  261. data/lib/ruby_llm/transcription.rb +10 -3
  262. data/lib/ruby_llm/transport/connection.rb +127 -44
  263. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  264. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  265. data/lib/ruby_llm/transport/json_response.rb +33 -0
  266. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  267. data/lib/ruby_llm/version.rb +1 -1
  268. data/lib/ruby_llm/video.rb +5 -3
  269. data/lib/ruby_llm/video_job.rb +48 -5
  270. data/lib/ruby_llm.rb +106 -5
  271. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  272. data/lib/tasks/ruby_llm.rake +6 -9
  273. data/skills/rubyllm/SKILL.md +33 -12
  274. metadata +96 -38
  275. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  278. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  279. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  280. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  281. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  282. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  283. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  284. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  285. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  286. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Hetzner Inference API integration.
6
+ class Hetzner < Provider
7
+ # Hetzner's dialect of the Chat Completions API.
8
+ class ChatCompletions < Protocols::ChatCompletions
9
+ include Hetzner::Chat
10
+ end
11
+
12
+ protocol :chat_completions, ChatCompletions
13
+
14
+ def api_base
15
+ @config.hetzner_api_base || 'https://inference.hetzner.com/api/v1'
16
+ end
17
+
18
+ def headers
19
+ { 'Authorization' => "Bearer #{@config.hetzner_api_key}" }
20
+ end
21
+
22
+ class << self
23
+ def configuration_options
24
+ %i[hetzner_api_key hetzner_api_base]
25
+ end
26
+
27
+ def configuration_requirements
28
+ %i[hetzner_api_key]
29
+ end
30
+
31
+ # Returns +true+: Hetzner Inference is experimental and changes its
32
+ # model selection faster than the registry tracks it, so model ids
33
+ # are accepted as given. Call RubyLLM.models.refresh to pull the
34
+ # live catalog.
35
+ def assume_models_exist?
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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
@@ -44,6 +47,10 @@ module RubyLLM
44
47
  payload = super
45
48
  payload.delete(:reasoning_effort)
46
49
  strip_schema_strict(payload)
50
+ if tool_prefs&.dig(:choice) == :none
51
+ payload.delete(:tools)
52
+ payload.delete(:parallel_tool_calls)
53
+ end
47
54
 
48
55
  reasoning = build_reasoning(thinking)
49
56
  payload[:reasoning] = reasoning if reasoning
@@ -82,32 +89,28 @@ module RubyLLM
82
89
  return {} unless msg.role == :assistant
83
90
  return { reasoning_details: msg.raw_reasoning } if msg.raw_reasoning.is_a?(Array)
84
91
 
85
- thinking = msg.thinking
86
- 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
87
97
 
88
- details = []
98
+ def reasoning_detail(thinking)
89
99
  if thinking.text
90
- details << {
91
- type: 'reasoning.text',
92
- text: thinking.text,
93
- signature: thinking.signature
94
- }.compact
100
+ { type: 'reasoning.text', text: thinking.text, signature: thinking.signature }.compact
95
101
  elsif thinking.signature
96
- details << {
97
- type: 'reasoning.encrypted',
98
- data: thinking.signature
99
- }
102
+ { type: 'reasoning.encrypted', data: thinking.signature }
100
103
  end
101
-
102
- details.empty? ? {} : { reasoning_details: details }
103
104
  end
104
105
 
105
106
  def format_message_content(msg, caching: nil)
106
107
  content = super
107
- 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)
108
111
  end
109
112
 
110
- def inject_cache_control(content, caching: nil)
113
+ def inject_cache_control(content, caching: nil, ttl: nil)
111
114
  blocks = content.is_a?(Array) ? content.dup : [{ type: 'text', text: content }]
112
115
  return blocks if blocks.empty?
113
116
 
@@ -115,15 +118,16 @@ module RubyLLM
115
118
  return blocks unless last.is_a?(Hash)
116
119
  return blocks if last[:cache_control] || last['cache_control']
117
120
 
118
- blocks[-1] = last.merge(cache_control: prompt_cache_control(caching))
121
+ blocks[-1] = last.merge(cache_control: prompt_cache_control(caching, ttl:))
119
122
  blocks
120
123
  end
121
124
 
122
- def prompt_cache_control(caching = nil)
125
+ def prompt_cache_control(caching = nil, ttl: nil)
123
126
  options = prompt_cache_options(caching)
127
+ ttl ||= options[:ttl]
124
128
 
125
129
  { type: CACHE_CONTROL_TYPE }.tap do |control|
126
- control[:ttl] = options[:ttl] if options[:ttl]
130
+ control[:ttl] = ttl if ttl
127
131
  end
128
132
  end
129
133
 
@@ -162,10 +166,6 @@ module RubyLLM
162
166
  OPENROUTER_INLINE_FILE_THRESHOLD
163
167
  end
164
168
 
165
- def provider_file_upload_limit
166
- OPENROUTER_FILE_UPLOAD_LIMIT
167
- end
168
-
169
169
  def provider_file_attachable?(attachment)
170
170
  attachment.pdf?
171
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