riffer 0.48.0 → 0.49.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 (258) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/comments.md +2 -4
  3. data/.claude/rules/rbs-inline.md +2 -12
  4. data/.release-please-manifest.json +1 -1
  5. data/.rubocop.yml +5 -0
  6. data/CHANGELOG.md +19 -0
  7. data/docs/AGENTS.md +3 -5
  8. data/docs/AGENT_LIFECYCLE.md +13 -16
  9. data/docs/CONFIGURATION.md +22 -33
  10. data/docs/TOOL_ADVANCED.md +1 -3
  11. data/docs/TRACING.md +1 -1
  12. data/docs/providers/AMAZON_BEDROCK.md +31 -0
  13. data/docs/providers/OPENROUTER.md +18 -1
  14. data/docs-site/build.rb +0 -6
  15. data/docs-site/check.rb +1 -5
  16. data/lib/riffer/agent/config.rb +4 -45
  17. data/lib/riffer/agent/context.rb +2 -26
  18. data/lib/riffer/agent/outcome.rb +0 -20
  19. data/lib/riffer/agent/response.rb +0 -38
  20. data/lib/riffer/agent/run.rb +11 -43
  21. data/lib/riffer/agent/serializer.rb +10 -39
  22. data/lib/riffer/agent/session/repair.rb +3 -15
  23. data/lib/riffer/agent/session.rb +25 -38
  24. data/lib/riffer/agent/structured_output/result.rb +0 -8
  25. data/lib/riffer/agent/structured_output.rb +0 -7
  26. data/lib/riffer/agent.rb +10 -130
  27. data/lib/riffer/config/amazon_bedrock.rb +30 -0
  28. data/lib/riffer/config/anthropic.rb +21 -0
  29. data/lib/riffer/config/azure_open_ai.rb +30 -0
  30. data/lib/riffer/config/evals.rb +18 -0
  31. data/lib/riffer/config/files.rb +61 -0
  32. data/lib/riffer/config/gemini.rb +21 -0
  33. data/lib/riffer/config/mcp.rb +31 -0
  34. data/lib/riffer/config/open_ai.rb +30 -0
  35. data/lib/riffer/config/open_router.rb +21 -0
  36. data/lib/riffer/config/pricing/rates.rb +36 -0
  37. data/lib/riffer/config/pricing.rb +64 -0
  38. data/lib/riffer/config/skills.rb +38 -0
  39. data/lib/riffer/config/tracing.rb +43 -0
  40. data/lib/riffer/config.rb +3 -362
  41. data/lib/riffer/evals/evaluator.rb +1 -29
  42. data/lib/riffer/evals/evaluator_runner.rb +0 -15
  43. data/lib/riffer/evals/judge.rb +0 -8
  44. data/lib/riffer/evals/result.rb +1 -11
  45. data/lib/riffer/evals/run_result.rb +0 -12
  46. data/lib/riffer/evals/scenario_result.rb +0 -19
  47. data/lib/riffer/files/downloader.rb +2 -3
  48. data/lib/riffer/files/resolver.rb +2 -7
  49. data/lib/riffer/guardrail.rb +2 -22
  50. data/lib/riffer/guardrails/modification.rb +0 -8
  51. data/lib/riffer/guardrails/result.rb +0 -17
  52. data/lib/riffer/guardrails/runner.rb +2 -11
  53. data/lib/riffer/guardrails/tripwire.rb +0 -8
  54. data/lib/riffer/guardrails.rb +0 -2
  55. data/lib/riffer/helpers/boolean.rb +0 -4
  56. data/lib/riffer/helpers/call_or_value.rb +0 -3
  57. data/lib/riffer/helpers/deep_dup.rb +3 -8
  58. data/lib/riffer/helpers/dependencies.rb +0 -4
  59. data/lib/riffer/helpers/identifier.rb +3 -12
  60. data/lib/riffer/helpers/validate.rb +42 -0
  61. data/lib/riffer/mcp/authenticated_tool.rb +4 -12
  62. data/lib/riffer/mcp/client.rb +0 -7
  63. data/lib/riffer/mcp/manifest.rb +3 -7
  64. data/lib/riffer/mcp/registration.rb +0 -10
  65. data/lib/riffer/mcp/registry.rb +0 -9
  66. data/lib/riffer/mcp/search_tool.rb +0 -4
  67. data/lib/riffer/mcp/tool.rb +1 -4
  68. data/lib/riffer/mcp/tool_factory.rb +3 -6
  69. data/lib/riffer/mcp.rb +2 -23
  70. data/lib/riffer/messages/assistant/reasoning_part.rb +4 -21
  71. data/lib/riffer/messages/assistant/tool_call.rb +1 -9
  72. data/lib/riffer/messages/assistant.rb +1 -21
  73. data/lib/riffer/messages/base.rb +0 -12
  74. data/lib/riffer/messages/system.rb +0 -3
  75. data/lib/riffer/messages/tool.rb +0 -15
  76. data/lib/riffer/messages/user/file_part.rb +2 -30
  77. data/lib/riffer/messages/user.rb +0 -4
  78. data/lib/riffer/params/boolean.rb +1 -5
  79. data/lib/riffer/params/param.rb +3 -31
  80. data/lib/riffer/params.rb +8 -41
  81. data/lib/riffer/providers/amazon_bedrock.rb +101 -42
  82. data/lib/riffer/providers/anthropic.rb +9 -24
  83. data/lib/riffer/providers/azure_open_ai.rb +3 -8
  84. data/lib/riffer/providers/base.rb +7 -28
  85. data/lib/riffer/providers/finish_reason.rb +0 -6
  86. data/lib/riffer/providers/gemini/client.rb +4 -17
  87. data/lib/riffer/providers/gemini.rb +4 -10
  88. data/lib/riffer/providers/mock.rb +1 -26
  89. data/lib/riffer/providers/open_ai.rb +7 -17
  90. data/lib/riffer/providers/open_router.rb +105 -30
  91. data/lib/riffer/providers/repository.rb +2 -14
  92. data/lib/riffer/providers/token_usage.rb +5 -14
  93. data/lib/riffer/registrable.rb +11 -45
  94. data/lib/riffer/runner/fibers.rb +1 -6
  95. data/lib/riffer/runner/sequential.rb +0 -1
  96. data/lib/riffer/runner/threaded.rb +0 -3
  97. data/lib/riffer/runner.rb +0 -3
  98. data/lib/riffer/skills/activate_tool.rb +0 -3
  99. data/lib/riffer/skills/adapter.rb +0 -8
  100. data/lib/riffer/skills/backend.rb +2 -7
  101. data/lib/riffer/skills/config.rb +4 -20
  102. data/lib/riffer/skills/context.rb +0 -29
  103. data/lib/riffer/skills/filesystem_backend.rb +0 -7
  104. data/lib/riffer/skills/frontmatter.rb +2 -16
  105. data/lib/riffer/skills/markdown_adapter.rb +3 -6
  106. data/lib/riffer/skills/xml_adapter.rb +0 -3
  107. data/lib/riffer/stream_events/base.rb +0 -3
  108. data/lib/riffer/stream_events/finish_reason_done.rb +1 -6
  109. data/lib/riffer/stream_events/guardrail_modification.rb +0 -10
  110. data/lib/riffer/stream_events/guardrail_tripwire.rb +0 -10
  111. data/lib/riffer/stream_events/interrupt.rb +2 -12
  112. data/lib/riffer/stream_events/reasoning_delta.rb +0 -3
  113. data/lib/riffer/stream_events/reasoning_done.rb +1 -5
  114. data/lib/riffer/stream_events/skill_activation.rb +0 -3
  115. data/lib/riffer/stream_events/text_delta.rb +0 -2
  116. data/lib/riffer/stream_events/text_done.rb +0 -2
  117. data/lib/riffer/stream_events/token_usage_done.rb +0 -2
  118. data/lib/riffer/stream_events/tool_call_delta.rb +1 -5
  119. data/lib/riffer/stream_events/tool_call_done.rb +0 -5
  120. data/lib/riffer/stream_events/web_search_done.rb +0 -3
  121. data/lib/riffer/stream_events/web_search_status.rb +1 -5
  122. data/lib/riffer/testing/minitest.rb +4 -5
  123. data/lib/riffer/testing.rb +5 -38
  124. data/lib/riffer/tool.rb +2 -28
  125. data/lib/riffer/tools/response.rb +3 -32
  126. data/lib/riffer/tools/runtime/fibers.rb +0 -6
  127. data/lib/riffer/tools/runtime/inline.rb +0 -1
  128. data/lib/riffer/tools/runtime/threaded.rb +0 -6
  129. data/lib/riffer/tools/runtime.rb +5 -26
  130. data/lib/riffer/tools/toolable.rb +0 -37
  131. data/lib/riffer/tracing/capture.rb +3 -5
  132. data/lib/riffer/tracing/no_op.rb +0 -7
  133. data/lib/riffer/tracing/otel.rb +7 -16
  134. data/lib/riffer/tracing/stream_recorder.rb +0 -7
  135. data/lib/riffer/tracing.rb +4 -27
  136. data/lib/riffer/version.rb +1 -1
  137. data/lib/riffer.rb +2 -30
  138. data/sig/generated/riffer/agent/config.rbs +12 -48
  139. data/sig/generated/riffer/agent/context.rbs +2 -26
  140. data/sig/generated/riffer/agent/outcome.rbs +0 -20
  141. data/sig/generated/riffer/agent/response.rbs +1 -29
  142. data/sig/generated/riffer/agent/run.rbs +1 -27
  143. data/sig/generated/riffer/agent/serializer.rbs +2 -27
  144. data/sig/generated/riffer/agent/session/repair.rbs +2 -10
  145. data/sig/generated/riffer/agent/session.rbs +10 -36
  146. data/sig/generated/riffer/agent/structured_output/result.rbs +0 -7
  147. data/sig/generated/riffer/agent/structured_output.rbs +0 -7
  148. data/sig/generated/riffer/agent.rbs +5 -121
  149. data/sig/generated/riffer/config/amazon_bedrock.rbs +21 -0
  150. data/sig/generated/riffer/config/anthropic.rbs +15 -0
  151. data/sig/generated/riffer/config/azure_open_ai.rbs +21 -0
  152. data/sig/generated/riffer/config/evals.rbs +13 -0
  153. data/sig/generated/riffer/config/files.rbs +43 -0
  154. data/sig/generated/riffer/config/gemini.rbs +15 -0
  155. data/sig/generated/riffer/config/mcp.rbs +19 -0
  156. data/sig/generated/riffer/config/open_ai.rbs +21 -0
  157. data/sig/generated/riffer/config/open_router.rbs +15 -0
  158. data/sig/generated/riffer/config/pricing/rates.rbs +19 -0
  159. data/sig/generated/riffer/config/pricing.rbs +31 -0
  160. data/sig/generated/riffer/config/skills.rbs +19 -0
  161. data/sig/generated/riffer/config/tracing.rbs +25 -0
  162. data/sig/generated/riffer/config.rbs +2 -307
  163. data/sig/generated/riffer/evals/evaluator.rbs +0 -28
  164. data/sig/generated/riffer/evals/evaluator_runner.rbs +0 -14
  165. data/sig/generated/riffer/evals/judge.rbs +0 -7
  166. data/sig/generated/riffer/evals/result.rbs +1 -11
  167. data/sig/generated/riffer/evals/run_result.rbs +0 -12
  168. data/sig/generated/riffer/evals/scenario_result.rbs +0 -14
  169. data/sig/generated/riffer/files/resolver.rbs +0 -7
  170. data/sig/generated/riffer/guardrail.rbs +2 -22
  171. data/sig/generated/riffer/guardrails/modification.rbs +0 -6
  172. data/sig/generated/riffer/guardrails/result.rbs +0 -15
  173. data/sig/generated/riffer/guardrails/runner.rbs +0 -11
  174. data/sig/generated/riffer/guardrails/tripwire.rbs +0 -8
  175. data/sig/generated/riffer/guardrails.rbs +0 -2
  176. data/sig/generated/riffer/helpers/boolean.rbs +0 -4
  177. data/sig/generated/riffer/helpers/call_or_value.rbs +0 -3
  178. data/sig/generated/riffer/helpers/deep_dup.rbs +0 -8
  179. data/sig/generated/riffer/helpers/dependencies.rbs +0 -4
  180. data/sig/generated/riffer/helpers/identifier.rbs +0 -12
  181. data/sig/generated/riffer/helpers/validate.rbs +21 -0
  182. data/sig/generated/riffer/mcp/authenticated_tool.rbs +0 -5
  183. data/sig/generated/riffer/mcp/client.rbs +0 -7
  184. data/sig/generated/riffer/mcp/manifest.rbs +3 -7
  185. data/sig/generated/riffer/mcp/registration.rbs +0 -10
  186. data/sig/generated/riffer/mcp/registry.rbs +0 -9
  187. data/sig/generated/riffer/mcp/search_tool.rbs +0 -4
  188. data/sig/generated/riffer/mcp/tool.rbs +0 -4
  189. data/sig/generated/riffer/mcp/tool_factory.rbs +0 -6
  190. data/sig/generated/riffer/mcp.rbs +2 -22
  191. data/sig/generated/riffer/messages/assistant/reasoning_part.rbs +4 -17
  192. data/sig/generated/riffer/messages/assistant/tool_call.rbs +1 -9
  193. data/sig/generated/riffer/messages/assistant.rbs +10 -30
  194. data/sig/generated/riffer/messages/base.rbs +0 -12
  195. data/sig/generated/riffer/messages/system.rbs +0 -3
  196. data/sig/generated/riffer/messages/tool.rbs +0 -12
  197. data/sig/generated/riffer/messages/user/file_part.rbs +0 -30
  198. data/sig/generated/riffer/messages/user.rbs +0 -4
  199. data/sig/generated/riffer/params/boolean.rbs +1 -4
  200. data/sig/generated/riffer/params/param.rbs +0 -31
  201. data/sig/generated/riffer/params.rbs +4 -40
  202. data/sig/generated/riffer/providers/amazon_bedrock.rbs +29 -25
  203. data/sig/generated/riffer/providers/anthropic.rbs +0 -10
  204. data/sig/generated/riffer/providers/azure_open_ai.rbs +0 -8
  205. data/sig/generated/riffer/providers/base.rbs +0 -28
  206. data/sig/generated/riffer/providers/finish_reason.rbs +0 -6
  207. data/sig/generated/riffer/providers/gemini/client.rbs +4 -17
  208. data/sig/generated/riffer/providers/gemini.rbs +0 -6
  209. data/sig/generated/riffer/providers/mock.rbs +0 -26
  210. data/sig/generated/riffer/providers/open_ai.rbs +2 -9
  211. data/sig/generated/riffer/providers/open_router.rbs +33 -14
  212. data/sig/generated/riffer/providers/repository.rbs +2 -14
  213. data/sig/generated/riffer/providers/token_usage.rbs +5 -14
  214. data/sig/generated/riffer/registrable.rbs +0 -45
  215. data/sig/generated/riffer/runner/fibers.rbs +0 -6
  216. data/sig/generated/riffer/runner/sequential.rbs +0 -1
  217. data/sig/generated/riffer/runner/threaded.rbs +0 -3
  218. data/sig/generated/riffer/runner.rbs +0 -3
  219. data/sig/generated/riffer/skills/activate_tool.rbs +0 -3
  220. data/sig/generated/riffer/skills/adapter.rbs +0 -8
  221. data/sig/generated/riffer/skills/backend.rbs +2 -7
  222. data/sig/generated/riffer/skills/config.rbs +0 -20
  223. data/sig/generated/riffer/skills/context.rbs +0 -29
  224. data/sig/generated/riffer/skills/filesystem_backend.rbs +0 -7
  225. data/sig/generated/riffer/skills/frontmatter.rbs +2 -16
  226. data/sig/generated/riffer/skills/markdown_adapter.rbs +0 -6
  227. data/sig/generated/riffer/skills/xml_adapter.rbs +0 -3
  228. data/sig/generated/riffer/stream_events/base.rbs +0 -3
  229. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +1 -6
  230. data/sig/generated/riffer/stream_events/guardrail_modification.rbs +0 -10
  231. data/sig/generated/riffer/stream_events/guardrail_tripwire.rbs +0 -10
  232. data/sig/generated/riffer/stream_events/interrupt.rbs +2 -10
  233. data/sig/generated/riffer/stream_events/reasoning_delta.rbs +0 -3
  234. data/sig/generated/riffer/stream_events/reasoning_done.rbs +1 -5
  235. data/sig/generated/riffer/stream_events/skill_activation.rbs +0 -3
  236. data/sig/generated/riffer/stream_events/text_delta.rbs +0 -2
  237. data/sig/generated/riffer/stream_events/text_done.rbs +0 -2
  238. data/sig/generated/riffer/stream_events/token_usage_done.rbs +0 -2
  239. data/sig/generated/riffer/stream_events/tool_call_delta.rbs +1 -5
  240. data/sig/generated/riffer/stream_events/tool_call_done.rbs +0 -5
  241. data/sig/generated/riffer/stream_events/web_search_done.rbs +0 -3
  242. data/sig/generated/riffer/stream_events/web_search_status.rbs +1 -5
  243. data/sig/generated/riffer/testing.rbs +0 -38
  244. data/sig/generated/riffer/tool.rbs +0 -27
  245. data/sig/generated/riffer/tools/response.rbs +3 -29
  246. data/sig/generated/riffer/tools/runtime/fibers.rbs +0 -5
  247. data/sig/generated/riffer/tools/runtime/inline.rbs +0 -1
  248. data/sig/generated/riffer/tools/runtime/threaded.rbs +0 -5
  249. data/sig/generated/riffer/tools/runtime.rbs +2 -24
  250. data/sig/generated/riffer/tools/toolable.rbs +0 -37
  251. data/sig/generated/riffer/tracing/capture.rbs +2 -5
  252. data/sig/generated/riffer/tracing/no_op.rbs +0 -7
  253. data/sig/generated/riffer/tracing/otel.rbs +7 -16
  254. data/sig/generated/riffer/tracing/stream_recorder.rbs +0 -2
  255. data/sig/generated/riffer/tracing.rbs +4 -23
  256. data/sig/generated/riffer.rbs +2 -29
  257. data/sig/manual/riffer/helpers/validate.rbs +5 -0
  258. metadata +30 -1
@@ -5,18 +5,8 @@ require "json"
5
5
  require "net/http"
6
6
  require "uri"
7
7
 
8
- # HTTP transport for the Gemini REST API. Riffer builds one from the
9
- # configured +api_key+ by default; construct your own to tune the HTTP knobs
10
- # and assign it to <tt>Riffer.config.gemini.client</tt>. Any object
11
- # implementing +post+ and +post_stream+ with these contracts works there —
12
- # the class is a default implementation, not a required base.
13
- #
14
- # Riffer.configure do |config|
15
- # config.gemini.client = Riffer::Providers::Gemini::Client.new(
16
- # api_key: ENV["GEMINI_API_KEY"],
17
- # read_timeout: 120
18
- # )
19
- # end
8
+ # <tt>Riffer.config.gemini.client</tt> accepts any object implementing +post+ and +post_stream+
9
+ # with these contracts; this class is the default, not a required base.
20
10
  class Riffer::Providers::Gemini::Client
21
11
  # @rbs @api_key: String?
22
12
  # @rbs @base_url: String
@@ -43,8 +33,7 @@ class Riffer::Providers::Gemini::Client
43
33
  @proxy_port = proxy_port
44
34
  end
45
35
 
46
- # POSTs a JSON body to an API path and returns the parsed response hash.
47
- # Raises Riffer::Error when the API responds with a non-success status.
36
+ # Raises Riffer::Error on a non-success status.
48
37
  #--
49
38
  #: (String, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
50
39
  def post(path, body)
@@ -54,9 +43,7 @@ class Riffer::Providers::Gemini::Client
54
43
  JSON.parse(response.body, symbolize_names: true)
55
44
  end
56
45
 
57
- # POSTs a JSON body to an API path, yielding raw response body chunks as
58
- # they arrive. Raises Riffer::Error when the API responds with a
59
- # non-success status.
46
+ # Raises Riffer::Error on a non-success status.
60
47
  #--
61
48
  #: (String, Hash[Symbol, untyped]) { (String) -> void } -> void
62
49
  def post_stream(path, body, &block)
@@ -4,7 +4,6 @@
4
4
  require "json"
5
5
  require "securerandom"
6
6
 
7
- # Google Gemini provider for Gemini models via the Gemini REST API.
8
7
  class Riffer::Providers::Gemini < Riffer::Providers::Base
9
8
  VALID_MODEL_PATTERN = /\A[a-zA-Z0-9._-]+\z/ #: Regexp
10
9
 
@@ -29,7 +28,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
29
28
  "FINISH_REASON_UNSPECIFIED" => :other,
30
29
  }.freeze #: Hash[String, Symbol]
31
30
 
32
- # The GenAI semconv well-known provider name.
33
31
  #--
34
32
  #: () -> String
35
33
  def self.semconv_provider_name
@@ -76,10 +74,8 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
76
74
  }]
77
75
  end
78
76
 
79
- # tags propagate to observability only: the Gemini Developer API has no
80
- # request labels field (unknown body fields are rejected), so :tags is
81
- # stripped here rather than mapped. Native labels would arrive with a Vertex
82
- # adapter. See docs/CONFIGURATION.md.
77
+ # The Gemini Developer API has no request labels field and rejects unknown
78
+ # body fields, so :tags reach observability only.
83
79
  generation_config = options.except(:tools, :structured_output, :tags)
84
80
 
85
81
  if structured_output
@@ -144,8 +140,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
144
140
  build_finish_reason(response.dig(:candidates, 0, :finishReason), tool_calls: has_function_call)
145
141
  end
146
142
 
147
- # Gemini reports STOP even when the candidate carries functionCall parts,
148
- # so tool-call presence overrides the raw value.
149
143
  #--
150
144
  #: (String?, tool_calls: bool) -> Riffer::Providers::FinishReason?
151
145
  def build_finish_reason(raw_reason, tool_calls:)
@@ -153,18 +147,18 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
153
147
 
154
148
  raw = raw_reason.to_s
155
149
  reason = FINISH_REASONS.fetch(raw, :other)
150
+ # Gemini reports STOP even when the candidate carries functionCall parts.
156
151
  reason = :tool_calls if reason == :stop && tool_calls
157
152
  Riffer::Providers::FinishReason.new(reason: reason, raw: raw)
158
153
  end
159
154
 
160
- # Gemini reports thinking tokens outside +candidatesTokenCount+;
161
- # TokenUsage's output includes them.
162
155
  #--
163
156
  #: (Hash[Symbol, untyped]) -> Riffer::Providers::TokenUsage
164
157
  def build_token_usage(usage)
165
158
  apply_pricing(
166
159
  Riffer::Providers::TokenUsage.new(
167
160
  input_tokens: usage[:promptTokenCount] || 0,
161
+ # Gemini reports thinking tokens outside candidatesTokenCount.
168
162
  output_tokens: (usage[:candidatesTokenCount] || 0) + (usage[:thoughtsTokenCount] || 0),
169
163
  cache_read_tokens: usage[:cachedContentTokenCount],
170
164
  ),
@@ -1,41 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Mock provider for mocking LLM responses in tests; no external gems required.
5
4
  class Riffer::Providers::Mock < Riffer::Providers::Base
6
5
  # @rbs @responses: Array[Hash[Symbol, untyped]]
7
6
  # @rbs @current_index: Integer
8
7
  # @rbs @stubbed_responses: Array[Hash[Symbol, untyped]]
9
8
 
10
- # Returns the skill adapter for the mock model — XML when the model name
11
- # contains +claude+ (mirroring a real Claude provider), else Markdown.
12
9
  #--
13
10
  #: (?String?) -> singleton(Riffer::Skills::Adapter)
14
11
  def self.skills_adapter(model = nil)
12
+ # Mirrors the adapter a real Claude provider picks.
15
13
  return Riffer::Skills::XmlAdapter if model&.include?("claude")
16
14
 
17
15
  Riffer::Skills::MarkdownAdapter
18
16
  end
19
17
 
20
- # The GenAI semconv well-known provider name.
21
18
  #--
22
19
  #: () -> String
23
20
  def self.semconv_provider_name
24
21
  "mock"
25
22
  end
26
23
 
27
- # Array of recorded method calls for assertions.
28
24
  attr_reader :calls #: Array[Hash[Symbol, untyped]] # @dynamic calls
29
25
 
30
- # +responses:+ pre-configures canned responses (same shape as
31
- # +#stub_response+) for standalone use; agent tests queue responses on
32
- # <tt>agent.provider</tt> via +#stub_response+ instead.
33
- #
34
- # Riffer::Providers::Mock.new(responses: [
35
- # {content: "", tool_calls: [{name: "tool_a", arguments: "{}"}]},
36
- # {content: "Final answer"}
37
- # ])
38
- #
39
26
  #--
40
27
  #: (?responses: Array[Hash[Symbol, untyped]]) -> void
41
28
  def initialize(responses: [])
@@ -46,16 +33,6 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
46
33
  @stubbed_responses = []
47
34
  end
48
35
 
49
- # Stubs the next response; call repeatedly to queue several. +finish_reason+
50
- # defaults to +:tool_calls+ when tool calls are present, else +:stop+.
51
- #
52
- # provider.stub_response("Hello")
53
- # provider.stub_response("", tool_calls: [{name: "my_tool", arguments: '{"key":"value"}'}])
54
- # provider.stub_response("Final response",
55
- # token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
56
- # provider.stub_response("Truncated...", finish_reason: :length)
57
- # provider.stub_response("Answer", reasoning: [{type: :text, text: "Thinking...", format: "mock-v1"}])
58
- #
59
36
  #--
60
37
  #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?, ?finish_reason: Symbol?, ?reasoning: Array[Hash[Symbol, untyped] | Riffer::Messages::Assistant::ReasoningPart]) -> void
61
38
  def stub_response(content, tool_calls: [], token_usage: nil, finish_reason: nil, reasoning: [])
@@ -68,8 +45,6 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
68
45
  )
69
46
  end
70
47
 
71
- # Clears all stubbed responses.
72
- #
73
48
  #--
74
49
  #: () -> void
75
50
  def clear_stubs
@@ -1,13 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # OpenAI provider for GPT models. Requires the +openai+ gem.
5
4
  class Riffer::Providers::OpenAI < Riffer::Providers::Base
6
5
  WEB_SEARCH_TOOL_TYPE = "web_search_preview" #: String
7
6
 
8
- # The Responses API has no finish_reason field. The response +status+ is
9
- # the primary signal; an +incomplete+ status is only meaningful together
10
- # with <tt>incomplete_details.reason</tt>, so that branch nests one level.
7
+ # The Responses API has no finish_reason field, and an +incomplete+ status is only meaningful
8
+ # together with <tt>incomplete_details.reason</tt>.
11
9
  FINISH_REASONS = {
12
10
  "completed" => :stop,
13
11
  "incomplete" => {
@@ -20,7 +18,6 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
20
18
  "queued" => :other,
21
19
  }.freeze #: Hash[String, Symbol | Hash[String, Symbol]]
22
20
 
23
- # The GenAI semconv well-known provider name.
24
21
  #--
25
22
  #: () -> String
26
23
  def self.semconv_provider_name
@@ -48,12 +45,11 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
48
45
  Riffer.config.openai.client
49
46
  end
50
47
 
51
- # Compacted so an unset value stays absent: the SDK reads +OPENAI_API_KEY+ /
52
- # +OPENAI_BASE_URL+ only for a missing argument, and an explicit nil would
53
- # suppress that fallback.
54
48
  #--
55
49
  #: () -> untyped
56
50
  def build_client
51
+ # The SDK falls back to +OPENAI_API_KEY+ / +OPENAI_BASE_URL+ only for a missing argument; an
52
+ # explicit nil suppresses that fallback.
57
53
  ::OpenAI::Client.new(**{
58
54
  api_key: Riffer.config.openai.api_key,
59
55
  base_url: Riffer.config.openai.base_url,
@@ -80,10 +76,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
80
76
  } #: Hash[Symbol, untyped]
81
77
 
82
78
  unless tags.empty?
83
- # Merged over any metadata set in model_options; a tag wins on a shared key.
84
79
  params[:metadata] = (params[:metadata] || {}).merge(tags)
85
- # The reserved user_id also maps to the native safety identifier while
86
- # staying in metadata as an ordinary tag.
87
80
  user_id = tags["user_id"]
88
81
  params[:safety_identifier] = user_id if user_id
89
82
  end
@@ -165,7 +158,6 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
165
158
  Riffer::Providers::FinishReason.new(reason: reason, raw: detail || status)
166
159
  end
167
160
 
168
- # The nested field that names the cause behind an ambiguous status.
169
161
  #--
170
162
  #: (untyped, String) -> String?
171
163
  def finish_detail(response, status)
@@ -255,9 +247,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
255
247
  end
256
248
  end
257
249
  ensure
258
- # OpenAI SDK does not auto-close the underlying HTTP stream when
259
- # iteration is interrupted (raise / fiber cancellation), so the SSE
260
- # socket leaks until GC. close is idempotent and a no-op after EOF.
250
+ # The SDK doesn't close the SSE socket when iteration is interrupted (raise / fiber
251
+ # cancellation), so it leaks until GC. close is idempotent and a no-op after EOF.
261
252
  stream.close
262
253
  end
263
254
 
@@ -349,8 +340,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
349
340
  action = event.item.action
350
341
  case action
351
342
  when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
352
- # OpenPage carries a url but no query or sources, so it doesn't fit
353
- # WebSearchDone — emit as a status notification instead.
343
+ # OpenPage carries a url but no query or sources, so it doesn't fit WebSearchDone.
354
344
  yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
355
345
  when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
356
346
  sources = (action.sources || []).map { |s| { title: nil, url: s.url } }
@@ -3,10 +3,6 @@
3
3
 
4
4
  require "json"
5
5
 
6
- # OpenRouter provider (https://openrouter.ai). Requires the +openai+ gem —
7
- # OpenRouter exposes an OpenAI-compatible endpoint, so this reuses the OpenAI
8
- # SDK with a +base_url+ override. +api_key+ resolves from config, then
9
- # +OPENROUTER_API_KEY+.
10
6
  class Riffer::Providers::OpenRouter < Riffer::Providers::Base
11
7
  BASE_URL = "https://openrouter.ai/api/v1" #: String
12
8
 
@@ -19,7 +15,27 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
19
15
  "error" => :error,
20
16
  }.freeze #: Hash[String, Symbol]
21
17
 
22
- # The GenAI semconv well-known provider name.
18
+ REASONING_TYPES = {
19
+ "reasoning.text" => :text,
20
+ "reasoning.summary" => :summary,
21
+ "reasoning.encrypted" => :encrypted,
22
+ }.freeze #: Hash[String, Symbol]
23
+
24
+ # The +reasoning_details+ formats OpenRouter documents. Only parts tagged with
25
+ # one of them are replayed, so parts from other adapters, or with no format,
26
+ # never reach the request.
27
+ REASONING_FORMATS = %w[
28
+ unknown
29
+ openai-responses-v1
30
+ azure-openai-responses-v1
31
+ bedrock-openai-responses-v1
32
+ bedrock-xai-responses-v1
33
+ xai-responses-v1
34
+ meta-responses-v1
35
+ anthropic-claude-v1
36
+ google-gemini-v1
37
+ ].freeze #: Array[String]
38
+
23
39
  #--
24
40
  #: () -> String
25
41
  def self.semconv_provider_name
@@ -47,15 +63,12 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
47
63
  Riffer.config.openrouter.client
48
64
  end
49
65
 
50
- # Deliberately not compacted: this borrows the OpenAI SDK to talk to a
51
- # different vendor, so omitting an unset +api_key+ would let the SDK fall
52
- # back to +OPENAI_API_KEY+ and send an OpenAI credential to OpenRouter.
53
- # Passing nil raises in the SDK instead. +OPENROUTER_API_KEY+ is read here
54
- # rather than left to the SDK for the same reason.
55
66
  #--
56
67
  #: () -> untyped
57
68
  def build_client
58
69
  api_key = Riffer.config.openrouter.api_key || ENV.fetch("OPENROUTER_API_KEY", nil)
70
+ # Pass a nil api_key rather than omitting it: an omitted key lets the SDK
71
+ # fall back to OPENAI_API_KEY and send an OpenAI credential to OpenRouter.
59
72
  ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL)
60
73
  end
61
74
 
@@ -74,7 +87,6 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
74
87
  } #: Hash[Symbol, untyped]
75
88
 
76
89
  unless tags.empty?
77
- # Merged over any metadata set in model_options; a tag wins on a shared key.
78
90
  params[:metadata] = (params[:metadata] || {}).merge(tags)
79
91
  # OpenRouter exposes the legacy Chat Completions user field rather than
80
92
  # safety_identifier; the reserved user_id maps there and stays in metadata.
@@ -138,8 +150,6 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
138
150
  build_finish_reason(choice&.finish_reason, native: native_finish_reason(choice))
139
151
  end
140
152
 
141
- # +native+ is the upstream model's own finish reason, which OpenRouter
142
- # reports alongside its normalized one; it wins as +raw+ when present.
143
153
  #--
144
154
  #: (untyped, ?native: untyped) -> Riffer::Providers::FinishReason?
145
155
  def build_finish_reason(finish_reason, native: nil)
@@ -148,15 +158,17 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
148
158
  normalized = finish_reason.to_s
149
159
  return nil if normalized.empty?
150
160
 
161
+ # OpenRouter reports the upstream model's own finish reason alongside its
162
+ # normalized one.
151
163
  raw = native.to_s.empty? ? normalized : native.to_s
152
164
  Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(normalized, :other), raw: raw)
153
165
  end
154
166
 
155
- # +native_finish_reason+ is outside the OpenAI schema, so it is only
156
- # reachable through the SDK model's raw data hash.
157
167
  #--
158
168
  #: (untyped) -> untyped
159
169
  def native_finish_reason(choice)
170
+ # Outside the OpenAI schema, so only reachable through the SDK model's raw
171
+ # data hash.
160
172
  choice && choice.to_h[:native_finish_reason]
161
173
  end
162
174
 
@@ -188,6 +200,39 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
188
200
  end
189
201
  end
190
202
 
203
+ #--
204
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
205
+ def extract_reasoning(response)
206
+ typed_response = response #: OpenAI::Models::Chat::ChatCompletion
207
+ message = typed_response.choices.first&.message
208
+ details = message && reasoning_details(message)
209
+ (details || []).filter_map { |detail| build_reasoning_part(detail) }
210
+ end
211
+
212
+ # The openai gem's typed models strip fields outside OpenAI's spec, so
213
+ # +reasoning_details+ is only reachable through the model's raw data hash.
214
+ #--
215
+ #: (untyped) -> Array[Hash[Symbol, untyped]]?
216
+ def reasoning_details(model)
217
+ model[:reasoning_details]
218
+ end
219
+
220
+ #--
221
+ #: (Hash[Symbol, untyped]) -> Riffer::Messages::Assistant::ReasoningPart?
222
+ def build_reasoning_part(detail)
223
+ type = REASONING_TYPES.fetch(detail[:type], nil)
224
+ return nil unless type
225
+
226
+ Riffer::Messages::Assistant::ReasoningPart.new(
227
+ type: type,
228
+ text: detail[:text] || detail[:summary],
229
+ data: detail[:data],
230
+ signature: detail[:signature],
231
+ id: detail[:id],
232
+ format: detail[:format],
233
+ )
234
+ end
235
+
191
236
  #--
192
237
  #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
193
238
  def execute_stream(params, yielder)
@@ -197,17 +242,14 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
197
242
 
198
243
  state = {
199
244
  text: +"",
200
- reasoning: +"",
245
+ reasoning_details: {},
201
246
  tool_calls: {},
202
247
  finish_reason: nil,
203
248
  native_finish_reason: nil,
204
249
  } #: Hash[Symbol, untyped]
205
250
 
206
- # Use stream_raw (not stream) — the latter yields a higher-level
207
- # ChatChunkEvent helper that aggregates content/tool calls into typed
208
- # events. We want raw ChatCompletionChunk objects with
209
- # +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
210
- # ourselves.
251
+ # stream_raw, not stream: stream aggregates chunks into higher-level
252
+ # events, but mapping to Riffer::StreamEvents needs the raw deltas.
211
253
  stream = client.chat.completions.stream_raw(**stream_params)
212
254
  begin
213
255
  stream.each do |chunk|
@@ -224,7 +266,10 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
224
266
  emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?
225
267
 
226
268
  yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
227
- yield_reasoning_done(yielder, state[:reasoning]) unless state[:reasoning].empty?
269
+ state[:reasoning_details].each_value do |detail|
270
+ part = build_reasoning_part(detail)
271
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part) if part
272
+ end
228
273
  yield_finish_reason(yielder, build_finish_reason(state[:finish_reason], native: state[:native_finish_reason]))
229
274
  end
230
275
 
@@ -265,14 +310,26 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
265
310
  #--
266
311
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
267
312
  def handle_reasoning_delta(delta, state:, yielder:)
268
- # The openai gem's typed Delta model strips fields not in OpenAI's spec
269
- # (so +delta.reasoning+ raises NoMethodError), but the underlying data
270
- # hash retains them. Access via +#[]+ which reads from BaseModel#@data.
271
- reasoning = delta[:reasoning] if delta.respond_to?(:[])
272
- return if reasoning.nil? || reasoning.empty?
273
-
274
- state[:reasoning] << reasoning
275
- yielder << Riffer::StreamEvents::ReasoningDelta.new(reasoning)
313
+ details = reasoning_details(delta)
314
+ return if details.nil?
315
+
316
+ details.each do |detail|
317
+ accumulate_reasoning_detail(state[:reasoning_details], detail)
318
+ text = detail[:text] || detail[:summary]
319
+ yielder << Riffer::StreamEvents::ReasoningDelta.new(text) unless text.nil? || text.empty?
320
+ end
321
+ end
322
+
323
+ # OpenRouter streams one reasoning block as many fragments sharing an
324
+ # +index+: prose and payload arrive in pieces to concatenate, while the
325
+ # signature, id, and format arrive once, often on the last fragment. Keying
326
+ # on +type+ as well keeps index-less fragments of different kinds apart.
327
+ #--
328
+ #: (Hash[untyped, Hash[Symbol, untyped]], Hash[Symbol, untyped]) -> void
329
+ def accumulate_reasoning_detail(accumulated, detail)
330
+ entry = accumulated[[detail[:index], detail[:type]]] ||= { type: detail[:type] }
331
+ %i[text summary data].each { |key| (entry[key] ||= +"") << detail[key] if detail[key] }
332
+ %i[signature id format].each { |key| entry[key] = detail[key] if detail[key] }
276
333
  end
277
334
 
278
335
  #--
@@ -369,9 +426,27 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
369
426
  end
370
427
  end
371
428
 
429
+ details = message.reasoning.filter_map do |part|
430
+ convert_reasoning_part_to_detail(part) if REASONING_FORMATS.include?(part.format)
431
+ end
432
+ msg[:reasoning_details] = details unless details.empty?
433
+
372
434
  msg
373
435
  end
374
436
 
437
+ #--
438
+ #: (Riffer::Messages::Assistant::ReasoningPart) -> Hash[Symbol, untyped]
439
+ def convert_reasoning_part_to_detail(part)
440
+ {
441
+ type: REASONING_TYPES.key(part.type),
442
+ (part.type == :summary ? :summary : :text) => part.text,
443
+ data: part.data,
444
+ signature: part.signature,
445
+ id: part.id,
446
+ format: part.format,
447
+ }.compact
448
+ end
449
+
375
450
  #--
376
451
  #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
377
452
  def convert_file_part_to_chat_completions_format(file)
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Resolves provider classes by identifier, combining the built-in REPO with
5
- # consumer registrations added through +register+. Registration is not
6
- # synchronized — register during boot, before concurrent generation begins.
7
4
  module Riffer::Providers::Repository
8
5
  extend self
9
6
 
@@ -22,11 +19,8 @@ module Riffer::Providers::Repository
22
19
 
23
20
  @registrations = {} #: Hash[Symbol, ^() -> singleton(Riffer::Providers::Base)]
24
21
 
25
- # Registers a custom provider under +identifier+, resolved lazily by the block.
26
- # Takes precedence over a built-in sharing the identifier.
27
- #
28
- # Riffer::Providers::Repository.register(:jane) { MyApp::JaneProvider }
29
- #
22
+ # Not synchronized — register during boot, before concurrent generation
23
+ # begins.
30
24
  #--
31
25
  #: ((String | Symbol)) { () -> singleton(Riffer::Providers::Base) } -> void
32
26
  def register(identifier, &factory)
@@ -34,8 +28,6 @@ module Riffer::Providers::Repository
34
28
  @key_for = nil
35
29
  end
36
30
 
37
- # Removes a custom registration by identifier, leaving any built-in of the
38
- # same name intact.
39
31
  #--
40
32
  #: ((String | Symbol)) -> void
41
33
  def unregister(identifier)
@@ -43,9 +35,6 @@ module Riffer::Providers::Repository
43
35
  @key_for = nil
44
36
  end
45
37
 
46
- # Finds a provider class by identifier, preferring a custom registration over
47
- # a built-in of the same name.
48
- #
49
38
  #--
50
39
  #: ((String | Symbol)) -> singleton(Riffer::Providers::Base)?
51
40
  def find(identifier)
@@ -53,7 +42,6 @@ module Riffer::Providers::Repository
53
42
  (@registrations[key] || REPO[key])&.call
54
43
  end
55
44
 
56
- # Returns the registry identifier for a provider class, or nil when unregistered.
57
45
  #--
58
46
  #: (singleton(Riffer::Providers::Base)) -> Symbol?
59
47
  def key_for(provider_class)
@@ -1,11 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Normalized token usage for an LLM API call. Buckets carry the same
5
- # meaning for every provider.
6
4
  class Riffer::Providers::TokenUsage
7
- # Builds a TokenUsage from a hash, or returns +usage+ unchanged when it is
8
- # already a TokenUsage.
9
5
  #--
10
6
  #: ((Hash[Symbol, untyped] | Riffer::Providers::TokenUsage)) -> Riffer::Providers::TokenUsage
11
7
  def self.from_hash(usage)
@@ -20,19 +16,19 @@ class Riffer::Providers::TokenUsage
20
16
  )
21
17
  end
22
18
 
23
- # Number of tokens entering the context window, including cache reads and writes.
19
+ # Normalized across providers: includes cache reads and writes.
24
20
  attr_reader :input_tokens #: Integer # @dynamic input_tokens
25
21
 
26
- # Number of tokens generated by the model, including reasoning/thinking tokens.
22
+ # Includes reasoning/thinking tokens.
27
23
  attr_reader :output_tokens #: Integer # @dynamic output_tokens
28
24
 
29
- # Subset of +input_tokens+ written to the provider's prompt cache, when the provider reports it.
25
+ # Subset of +input_tokens+.
30
26
  attr_reader :cache_write_tokens #: Integer? # @dynamic cache_write_tokens
31
27
 
32
- # Subset of +input_tokens+ read from the provider's prompt cache, when the provider reports it.
28
+ # Subset of +input_tokens+.
33
29
  attr_reader :cache_read_tokens #: Integer? # @dynamic cache_read_tokens
34
30
 
35
- # Cost of the call, set when the model is priced. For observability, not billing.
31
+ # For observability, not billing.
36
32
  attr_reader :cost #: Float? # @dynamic cost
37
33
 
38
34
  #--
@@ -45,16 +41,12 @@ class Riffer::Providers::TokenUsage
45
41
  @cost = cost
46
42
  end
47
43
 
48
- # Returns the total number of tokens (input + output).
49
- #
50
44
  #--
51
45
  #: () -> Integer
52
46
  def total_tokens
53
47
  input_tokens + output_tokens
54
48
  end
55
49
 
56
- # Combines two TokenUsage objects for cumulative tracking.
57
- #
58
50
  #--
59
51
  #: (Riffer::Providers::TokenUsage) -> Riffer::Providers::TokenUsage
60
52
  def +(other)
@@ -67,7 +59,6 @@ class Riffer::Providers::TokenUsage
67
59
  )
68
60
  end
69
61
 
70
- # Converts the token usage to a hash; cache tokens and cost are omitted when nil.
71
62
  #--
72
63
  #: () -> Hash[Symbol, (Integer | Float)]
73
64
  def to_h