ruby_llm 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Protocol
5
+ module RequestShapes
6
+ # Finds what a provider would refuse in the turns a protocol read: parts
7
+ # with no data, empty turns, tool rounds whose calls and results do not
8
+ # pair up, and results that answer no call, plus the rules a protocol
9
+ # opts into. +pairing+ is how the provider matches results to calls:
10
+ # +:id+, or +:position+, which compares tool names in order. The rules
11
+ # are +:signed_steps+, where the first call of each step in the current
12
+ # turn needs a signature; +:signed_thinking+, where every thinking part
13
+ # does; and +:alternating_roles+, where user and model turns alternate,
14
+ # starting with the user.
15
+ class Analysis # :nodoc: all
16
+ KEPT_FIRST_TURNS = 10
17
+ KEPT_LAST_TURNS = 50
18
+
19
+ Round = Struct.new(:turn, :calls, :results)
20
+
21
+ def initialize(turns, pairing:, rules:)
22
+ @turns = turns
23
+ @pairing = pairing
24
+ @rules = rules
25
+ name_results
26
+ end
27
+
28
+ def kept_turns
29
+ return @turns if @turns.size <= KEPT_FIRST_TURNS + KEPT_LAST_TURNS
30
+
31
+ @turns.first(KEPT_FIRST_TURNS) + @turns.last(KEPT_LAST_TURNS)
32
+ end
33
+
34
+ # The request is the model call after every round of the current
35
+ # turn, which starts after the last user turn.
36
+ def step
37
+ rounds.count { |round| round.turn.index > last_user_turn.index } + 1 if last_user_turn
38
+ end
39
+
40
+ def tool_rounds
41
+ kept = kept_turns.to_set(&:index)
42
+ rounds.select { |round| kept.include?(round.turn.index) }.map do |round|
43
+ RequestShape::ToolRound.new(turn: round.turn.index, calls: round.calls.size,
44
+ results: round.results.size, paired: paired?(round))
45
+ end
46
+ end
47
+
48
+ def problems
49
+ found = [*parts_without_data, *empty_turns, *unpaired_rounds, *unmatched_results, *unsigned_calls,
50
+ *unsigned_thinking, *roles_out_of_order]
51
+ found.each_with_index.sort_by { |problem, order| [problem.turn, problem.part || -1, order] }.map(&:first)
52
+ end
53
+
54
+ private
55
+
56
+ def located_parts(turns = @turns)
57
+ turns.flat_map { |turn| turn.parts.each_with_index.map { |part, position| [turn, position, part] } }
58
+ end
59
+
60
+ def last_user_turn
61
+ @turns.reverse.find { |turn| turn.speaker == :user }
62
+ end
63
+
64
+ def name_results
65
+ return unless @pairing == :id
66
+
67
+ names = {}
68
+ located_parts.each do |_, _, part|
69
+ names[part.call_id] = part.name if part.call? && part.call_id
70
+ part.name ||= names[part.result_id] if part.result?
71
+ end
72
+ end
73
+
74
+ # Consecutive model turns make one round, answered by the results in
75
+ # the turns that follow them up to the next model turn.
76
+ def rounds
77
+ @rounds ||= runs(@turns).push([]).each_cons(2).filter_map { |run, answers| round(run, answers) }
78
+ end
79
+
80
+ def round(run, answers)
81
+ calls = run.flat_map(&:parts).select(&:call?)
82
+ Round.new(run.first, calls, answers.flat_map(&:parts).select(&:result?)) if run.first.model? && calls.any?
83
+ end
84
+
85
+ def runs(turns)
86
+ turns.slice_when { |turn, following| turn.model? != following.model? }.to_a
87
+ end
88
+
89
+ def paired?(round)
90
+ calls = round.calls
91
+ results = round.results
92
+ return false unless calls.size == results.size
93
+ return calls.map(&:name) == results.map(&:name) unless @pairing == :id && (calls + results).all? { ids?(_1) }
94
+
95
+ calls.map(&:call_id).tally == results.map(&:result_id).tally
96
+ end
97
+
98
+ def ids?(part)
99
+ part.call? ? part.call_id : part.result_id
100
+ end
101
+
102
+ def parts_without_data
103
+ located_parts.filter_map do |turn, position, part|
104
+ next unless part.without_data
105
+
106
+ noun = part.kind == :other ? 'part' : "#{part.kind} part"
107
+ problem(:part_without_data, "#{noun} carries no data", turn:, part: position)
108
+ end
109
+ end
110
+
111
+ def empty_turns
112
+ @turns.select { |turn| turn.parts.empty? }.map { |turn| problem(:empty_turn, 'turn has no parts', turn:) }
113
+ end
114
+
115
+ def unpaired_rounds
116
+ rounds.reject { |round| paired?(round) }.map do |round|
117
+ calls = counted(round.calls.size, 'call')
118
+ results = counted(round.results.size, 'result')
119
+ message = if round.calls.size == round.results.size
120
+ "#{calls} and #{results} that do not pair up"
121
+ else
122
+ "#{calls} but #{results}"
123
+ end
124
+ problem(:unpaired_round, message, turn: round.turn)
125
+ end
126
+ end
127
+
128
+ # The one place a shape shows an id: a result naming a call the
129
+ # request never made can only be traced by the id it names.
130
+ def unmatched_results
131
+ return [] unless @pairing == :id
132
+
133
+ called = Set.new
134
+ located_parts.filter_map do |turn, position, part|
135
+ called << part.call_id if part.call? && part.call_id
136
+ next unless part.result? && !called.include?(part.result_id)
137
+
138
+ message = 'result answers no call in the request'
139
+ problem(:unmatched_result, message, turn:, part: position, call_id: part.result_id)
140
+ end
141
+ end
142
+
143
+ def unsigned_calls
144
+ return [] unless @rules.include?(:signed_steps)
145
+
146
+ start = last_user_turn ? @turns.index(last_user_turn) + 1 : 0
147
+ runs(@turns.drop(start)).select { |run| run.first.model? }.filter_map do |run|
148
+ turn, position, call = located_parts(run).find { |_, _, part| part.call? }
149
+ next if call.nil? || call.signed
150
+
151
+ message = 'first call of a step in the current turn has no signature'
152
+ problem(:unsigned_call, message, turn:, part: position)
153
+ end
154
+ end
155
+
156
+ def unsigned_thinking
157
+ return [] unless @rules.include?(:signed_thinking)
158
+
159
+ located_parts.filter_map do |turn, position, part|
160
+ next unless part.kind == :thinking && !part.signed
161
+
162
+ problem(:unsigned_thinking, 'thinking part has no signature', turn:, part: position)
163
+ end
164
+ end
165
+
166
+ def roles_out_of_order
167
+ return [] unless @rules.include?(:alternating_roles)
168
+
169
+ turns = @turns.reject { |turn| turn.speaker == :system }
170
+ problems = turns.each_cons(2).filter_map do |turn, following|
171
+ next unless turn.model? == following.model?
172
+
173
+ problem(:role_order, "follows another #{following.model? ? 'model' : 'user'} turn", turn: following)
174
+ end
175
+ first = turns.first
176
+ problems.unshift(problem(:role_order, 'conversation starts with a model turn', turn: first)) if first&.model?
177
+ problems
178
+ end
179
+
180
+ def problem(kind, message, turn:, part: nil, call_id: nil)
181
+ RequestShape::Problem.new(kind:, message:, turn: turn.index, part:, call_id:)
182
+ end
183
+
184
+ def counted(count, noun)
185
+ "#{count} #{noun}#{'s' unless count == 1}"
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ class Protocol
7
+ # Builds the RequestShape a protocol returns from parse_request_shape. A
8
+ # protocol reads its payload into turns of parts, keeping each piece's
9
+ # size and never its content, and hands them to shape_request with the
10
+ # rules its provider enforces; Analysis then finds the problems.
11
+ module RequestShapes # :nodoc: all
12
+ SETTING_VALUE = /\A[\w.-]{1,40}\z/
13
+
14
+ # A piece of a turn as a protocol reads it, with the ids and flags the
15
+ # analysis needs and a shape never shows.
16
+ PartSpec = Struct.new(:kind, :measure, :unit, :name, :source, :mime_type, :signed, :call_id, :result_id,
17
+ :without_data, keyword_init: true) do
18
+ def call?
19
+ kind == :tool_call
20
+ end
21
+
22
+ def result?
23
+ kind == :tool_result
24
+ end
25
+
26
+ def to_part
27
+ RequestShape::Part.new(kind:, size: measure, unit:, name:, source:, mime_type:, signed: signed || false)
28
+ end
29
+ end
30
+
31
+ # A turn as a protocol reads it. Its speaker, +:system+, +:user+,
32
+ # +:model+, or +:tool+, tells the analysis whose turn it is.
33
+ TurnSpec = Struct.new(:index, :role, :speaker, :parts, keyword_init: true) do
34
+ def model?
35
+ speaker == :model
36
+ end
37
+
38
+ def to_turn
39
+ RequestShape::Turn.new(index:, role:, parts: parts.map(&:to_part))
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def shape_request(turns, payload:, instructions: [], tool_names: [], thinking_settings: {}, pairing: :id,
46
+ rules: [])
47
+ analysis = Analysis.new(turns, pairing:, rules:)
48
+ kept = analysis.kept_turns
49
+ RequestShape.new(
50
+ provider: @provider&.slug, model: @model&.id, step: analysis.step, payload_keys: payload.keys,
51
+ thinking_settings: thinking_settings, instructions: instructions.map(&:to_part),
52
+ turns: kept.map(&:to_turn), turn_count: turns.size, omitted_turns: turns.size - kept.size,
53
+ tool_names: shape_list(tool_names).filter_map { |name| shape_name(name) },
54
+ tool_rounds: analysis.tool_rounds, problems: analysis.problems
55
+ )
56
+ end
57
+
58
+ def shape_turn(index, role, speaker, parts)
59
+ TurnSpec.new(index:, role: shape_name(role), speaker:, parts: parts.flatten.compact)
60
+ end
61
+
62
+ # A turn on the user's side that carries tool results answers a model
63
+ # turn instead of starting a new one.
64
+ def shape_answer(parts)
65
+ parts.flatten.compact.any?(&:result?) ? :tool : :user
66
+ end
67
+
68
+ def shape_instructions(text)
69
+ text.nil? ? [] : [shape_text(text)]
70
+ end
71
+
72
+ def shape_text(text, kind: :text, signed: false)
73
+ PartSpec.new(kind:, measure: shape_length(text), signed:, without_data: kind == :text && text.nil?)
74
+ end
75
+
76
+ def shape_summary(summary, signed: false)
77
+ parts = shape_list(summary)
78
+ measure = parts.sum { |part| shape_length(shape_hash(part)['text']).to_i } unless parts.empty?
79
+ PartSpec.new(kind: :thinking, measure:, signed:)
80
+ end
81
+
82
+ def shape_tool_call(name, arguments, id: nil, signed: false)
83
+ PartSpec.new(kind: :tool_call, name: shape_name(name), measure: shape_length(arguments),
84
+ call_id: shape_name(id), signed:)
85
+ end
86
+
87
+ # A result made of content blocks counts the characters of its text,
88
+ # and its other blocks, such as images, follow it as parts of their
89
+ # own. The block reads one content block in the protocol's format.
90
+ def shape_tool_result(content, name: nil, id: nil)
91
+ result = PartSpec.new(kind: :tool_result, name: shape_name(name), result_id: shape_name(id))
92
+ unless content.is_a?(Array) && block_given?
93
+ result.measure = shape_length(content)
94
+ return [result]
95
+ end
96
+
97
+ texts, others = content.flat_map { |block| yield shape_hash(block) }.partition { |part| part.kind == :text }
98
+ result.measure = texts.sum { |part| part.measure.to_i }
99
+ [result, *others]
100
+ end
101
+
102
+ def shape_other(name, signed: false)
103
+ PartSpec.new(kind: :other, name: shape_name(name), signed:)
104
+ end
105
+
106
+ def shape_media(kind, source:, size: nil, unit: nil, mime_type: nil, signed: false)
107
+ mime_type = shape_name(mime_type)
108
+ PartSpec.new(kind: kind || shape_media_kind(mime_type), measure: size, unit:, source:, mime_type:, signed:)
109
+ end
110
+
111
+ def shape_inline(data, kind: nil, mime_type: nil, signed: false)
112
+ shape_media(kind, source: :inline, size: shape_bytes(data), unit: :bytes, mime_type:, signed:)
113
+ end
114
+
115
+ def shape_file(kind: nil, mime_type: nil)
116
+ shape_media(kind, source: :file, mime_type:)
117
+ end
118
+
119
+ # A URL field can hold a data URI instead of a link.
120
+ def shape_url(url, kind: nil, mime_type: nil)
121
+ return shape_media(kind, source: :url, mime_type:) unless url.is_a?(String) && url.start_with?('data:')
122
+
123
+ header, data = url.split(',', 2)
124
+ shape_inline(data, kind:, mime_type: header.delete_prefix('data:').split(';').first)
125
+ end
126
+
127
+ def shape_media_kind(mime_type)
128
+ type = mime_type.to_s
129
+ return :image if Files::MimeType.image?(type)
130
+ return :audio if Files::MimeType.audio?(type)
131
+ return :video if Files::MimeType.video?(type)
132
+
133
+ :document
134
+ end
135
+
136
+ def shape_tool_name(tool)
137
+ tool = shape_hash(tool)
138
+ tool['name'] || tool['type']
139
+ end
140
+
141
+ # Thinking settings are short words and numbers, such as an effort or
142
+ # a budget, so anything else is left out rather than risk content.
143
+ def shape_settings(settings, *names)
144
+ shape_hash(settings).slice(*names).select do |_, value|
145
+ [Integer, Float, TrueClass, FalseClass].any? { |type| value.is_a?(type) } ||
146
+ (value.is_a?(String) && value.match?(SETTING_VALUE))
147
+ end
148
+ end
149
+
150
+ def shape_signature?(signature)
151
+ signature.is_a?(String) && !signature.empty?
152
+ end
153
+
154
+ def shape_name(value)
155
+ value if value.is_a?(String) && !value.empty?
156
+ end
157
+
158
+ def shape_length(value)
159
+ case value
160
+ when nil then nil
161
+ when String then value.length
162
+ else JSON.generate(value).length
163
+ end
164
+ end
165
+
166
+ # Base64 carries three bytes in every four characters, and its padding
167
+ # marks the missing bytes of the last group.
168
+ def shape_bytes(data)
169
+ return unless data.is_a?(String)
170
+
171
+ ((data.length - data.count("\r\n")) * 3 / 4) - [data.count('='), 2].min
172
+ end
173
+
174
+ def shape_hash(value)
175
+ value.is_a?(Hash) ? value : {}
176
+ end
177
+
178
+ def shape_list(value)
179
+ value.is_a?(Array) ? value : []
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Protocol
5
+ # Reuses the provider file an attachment's store recorded in an earlier
6
+ # process. Before a process first reuses a file, the provider confirms
7
+ # it still has it; a file that is missing or expired is uploaded again.
8
+ class StoredUploads # :nodoc:
9
+ FILES = Support::ProcessCache.new(limit: 1024)
10
+
11
+ def self.files
12
+ FILES
13
+ end
14
+
15
+ def initialize(provider, store)
16
+ @provider = provider
17
+ @store = store
18
+ @account = provider.account_identity if store
19
+ end
20
+
21
+ def fetch
22
+ return yield unless @account
23
+
24
+ stored_file || remember(yield)
25
+ end
26
+
27
+ def forget(upload)
28
+ return unless @account
29
+
30
+ FILES.delete(key(upload.id))
31
+ @store.forget(upload.id, provider: @provider.slug, account: @account)
32
+ end
33
+
34
+ private
35
+
36
+ def stored_file
37
+ stored = @store.fetch(provider: @provider.slug, account: @account)
38
+ return if stored.nil? || stored.expired?
39
+
40
+ file = confirmed_file(stored.id)
41
+ file unless file.nil? || file.expired?
42
+ end
43
+
44
+ def confirmed_file(id)
45
+ FILES.fetch(key(id)) { @provider.find_file(id) }
46
+ rescue StandardError => e
47
+ RubyLLM.logger.debug { "Uploading again: #{@provider.slug} could not find #{id} (#{e.message})" }
48
+ nil
49
+ end
50
+
51
+ def remember(upload)
52
+ FILES.fetch(key(upload.id)) { upload }
53
+ @store.store(upload, provider: @provider.slug, account: @account)
54
+ upload
55
+ end
56
+
57
+ def key(id)
58
+ [@provider.slug, @account, id]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -11,16 +11,11 @@ module RubyLLM
11
11
  def initialize
12
12
  @content = +''
13
13
  @citations = []
14
+ @citation_keys = {}
14
15
  @thinking_text = nil
15
16
  @thinking_signature = nil
16
17
  @tool_calls = {}
17
- @input_tokens = nil
18
- @output_tokens = nil
19
- @cache_read_tokens = nil
20
- @cache_write_tokens = nil
21
- @thinking_tokens = nil
22
- @server_tool_use = nil
23
- @reported_cost = nil
18
+ @tokens = Tokens.new
24
19
  @server_tool_calls = []
25
20
  @raw_content = nil
26
21
  @raw_reasoning = nil
@@ -53,15 +48,7 @@ module RubyLLM
53
48
  text: @thinking_text,
54
49
  signature: @thinking_signature
55
50
  ),
56
- tokens: Tokens.new(
57
- input: @input_tokens,
58
- output: @output_tokens,
59
- cache_read: @cache_read_tokens,
60
- cache_write: @cache_write_tokens,
61
- thinking: @thinking_tokens,
62
- server_tool_use: @server_tool_use,
63
- reported_cost: @reported_cost
64
- ),
51
+ tokens: @tokens,
65
52
  server_tool_calls: @server_tool_calls,
66
53
  raw_content: @raw_content,
67
54
  raw_reasoning: @raw_reasoning,
@@ -77,7 +64,11 @@ module RubyLLM
77
64
  # Providers like Perplexity repeat the full citation list on every chunk.
78
65
  def accumulate_citations(new_citations)
79
66
  new_citations.each do |citation|
80
- @citations << citation unless @citations.include?(citation)
67
+ key = citation.to_h
68
+ next if @citation_keys.key?(key)
69
+
70
+ @citation_keys[key] = true
71
+ @citations << citation
81
72
  end
82
73
  end
83
74
 
@@ -182,14 +173,7 @@ module RubyLLM
182
173
  end
183
174
 
184
175
  def count_tokens(chunk)
185
- tokens = chunk.tokens
186
- @input_tokens = tokens.input if tokens.input
187
- @output_tokens = tokens.output if tokens.output
188
- @cache_read_tokens = tokens.cache_read if tokens.cache_read
189
- @cache_write_tokens = tokens.cache_write if tokens.cache_write
190
- @thinking_tokens = tokens.thinking if tokens.thinking
191
- @server_tool_use = tokens.server_tool_use if tokens.server_tool_use
192
- @reported_cost = tokens.reported_cost if tokens.reported_cost
176
+ @tokens = @tokens.merge(chunk.tokens)
193
177
  end
194
178
 
195
179
  def handle_chunk_content(chunk)
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'event_stream_parser'
4
3
  require 'faraday'
5
4
  require 'json'
6
5
 
7
6
  module RubyLLM
8
7
  class Protocol
9
8
  module Streaming # :nodoc: all
10
- StreamState = Struct.new(:parser, :buffer) do
9
+ MAX_JSON_BODY_BYTES = 1024 * 1024
10
+
11
+ StreamState = Struct.new(:parser, :buffer, :json_body) do
11
12
  def initialize
12
- super(EventStreamParser::Parser.new, +'')
13
+ super
14
+ reset
15
+ end
16
+
17
+ def reset
18
+ self.parser = Transport::EventStreamParser.new
19
+ self.buffer = +''
20
+ self.json_body = nil
13
21
  end
14
22
  end
15
23
 
@@ -33,13 +41,15 @@ module RubyLLM
33
41
  # parsed event Hash. Returns the Faraday response.
34
42
  def stream_events(url, payload, additional_headers = {}, &block)
35
43
  progress = {}
36
- on_data = build_on_data_handler(progress) do |data|
44
+ fallback_state = StreamState.new
45
+ on_data = build_on_data_handler(progress, fallback_state: fallback_state) do |data|
37
46
  block.call(data) if data.is_a?(Hash)
38
47
  end
39
48
 
40
- @connection.post url, payload, usage: @usage_tracker do |req|
49
+ @connection.post url, payload, usage: @usage_tracker, stream: true do |req|
41
50
  req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
42
51
  (req.options.context ||= {})[Transport::Connection::STREAM_PROGRESS_KEY] = progress
52
+ req.options.context[Transport::ErrorMiddleware::STREAM_RESET_KEY] = fallback_state.method(:reset)
43
53
  if faraday_1?
44
54
  req.options[:on_data] = on_data
45
55
  else
@@ -60,12 +70,9 @@ module RubyLLM
60
70
  Faraday::VERSION.start_with?('1')
61
71
  end
62
72
 
63
- # Parser state lives on the env so every retry attempt starts fresh;
64
- # ErrorMiddleware clears it per attempt. Faraday v1 passes no env to
65
- # on_data, so it falls back to one state for the whole request.
66
- def build_on_data_handler(progress = {}, &handler)
67
- fallback_state = StreamState.new
68
-
73
+ # Some adapters omit the env, so ErrorMiddleware also resets the
74
+ # request's fallback state before each attempt.
75
+ def build_on_data_handler(progress = {}, fallback_state: StreamState.new, &handler)
69
76
  FaradayHandlers.build(
70
77
  faraday_v1: faraday_1?,
71
78
  on_chunk: lambda { |chunk, env|
@@ -86,36 +93,35 @@ module RubyLLM
86
93
  def process_stream_chunk(chunk, state, env, progress, &)
87
94
  RubyLLM.logger.debug { "Received chunk: #{chunk}" } if RubyLLM.config.log_stream_debug
88
95
 
89
- if error_chunk?(chunk)
90
- handle_error_chunk(chunk, env)
91
- elsif json_error_payload?(chunk)
92
- handle_json_error_chunk(chunk, env)
96
+ if json_body?(chunk, state)
97
+ handle_json_body(chunk, state, env)
93
98
  else
94
99
  handle_sse(chunk, state.parser, env, progress, &)
95
100
  end
96
101
  end
97
102
 
98
- # An error event split across network reads reaches here in pieces, so only
99
- # a whole one takes this shortcut; the rest go to the parser, which buffers
100
- # them until the event is complete.
101
- def error_chunk?(chunk)
102
- chunk.start_with?('event: error') && chunk.end_with?("\n\n")
103
+ # Adapters cut reads anywhere, so a read inside an event can start with
104
+ # "{". Only the first bytes of a body can open a bare JSON error.
105
+ def json_body?(chunk, state)
106
+ state.json_body = chunk.lstrip.start_with?('{') if state.json_body.nil? && !chunk.strip.empty?
107
+ state.json_body
103
108
  end
104
109
 
105
- def json_error_payload?(chunk)
106
- chunk.lstrip.start_with?('{') && chunk.include?('"error"')
107
- end
108
-
109
- def handle_json_error_chunk(chunk, env)
110
- parse_error_from_json(chunk, env, 'Failed to parse JSON error chunk')
111
- end
110
+ def handle_json_body(chunk, state, env)
111
+ if state.buffer.bytesize + chunk.bytesize > MAX_JSON_BODY_BYTES
112
+ raise Error, "Streaming JSON response exceeds #{MAX_JSON_BODY_BYTES} bytes"
113
+ end
112
114
 
113
- def handle_error_chunk(chunk, env)
114
- error_data = chunk.split("\n")[1]&.delete_prefix('data: ')
115
- parse_error_from_json(error_data, env, 'Failed to parse error chunk') if error_data
115
+ state.buffer << chunk
116
+ parsed = JSON.parse(state.buffer)
117
+ raise_stream_error(state.buffer, parsed, env) if parsed.is_a?(Hash) && parsed['error']
118
+ rescue JSON::ParserError
119
+ RubyLLM.logger.debug { 'Waiting for the remaining JSON response bytes' }
116
120
  end
117
121
 
118
122
  def handle_failed_response(chunk, buffer, env)
123
+ return if buffer.bytesize + chunk.bytesize > MAX_JSON_BODY_BYTES
124
+
119
125
  buffer << chunk
120
126
  error_data = JSON.parse(buffer)
121
127
  raise_stream_error(buffer, error_data, env)
@@ -184,7 +190,8 @@ module RubyLLM
184
190
  if faraday_1? || env.nil?
185
191
  Struct.new(:body, :status).new(parsed_data, error_status)
186
192
  else
187
- env.merge(body: parsed_data, status: error_status)
193
+ # Faraday writes body into the request until a status is set.
194
+ env.merge(status: error_status, body: parsed_data)
188
195
  end
189
196
  end
190
197