riffer 0.48.0 → 0.50.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 (262) 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 +32 -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/STREAM_EVENTS.md +3 -3
  11. data/docs/TOOL_ADVANCED.md +1 -3
  12. data/docs/TRACING.md +1 -1
  13. data/docs/providers/AMAZON_BEDROCK.md +31 -0
  14. data/docs/providers/ANTHROPIC.md +10 -0
  15. data/docs/providers/AZURE_OPENAI.md +2 -0
  16. data/docs/providers/OPENAI.md +22 -0
  17. data/docs/providers/OPENROUTER.md +18 -1
  18. data/docs-site/build.rb +0 -6
  19. data/docs-site/check.rb +1 -5
  20. data/lib/riffer/agent/config.rb +4 -45
  21. data/lib/riffer/agent/context.rb +2 -26
  22. data/lib/riffer/agent/outcome.rb +0 -20
  23. data/lib/riffer/agent/response.rb +0 -38
  24. data/lib/riffer/agent/run.rb +11 -43
  25. data/lib/riffer/agent/serializer.rb +10 -39
  26. data/lib/riffer/agent/session/repair.rb +3 -15
  27. data/lib/riffer/agent/session.rb +25 -38
  28. data/lib/riffer/agent/structured_output/result.rb +0 -8
  29. data/lib/riffer/agent/structured_output.rb +0 -7
  30. data/lib/riffer/agent.rb +10 -130
  31. data/lib/riffer/config/amazon_bedrock.rb +30 -0
  32. data/lib/riffer/config/anthropic.rb +21 -0
  33. data/lib/riffer/config/azure_open_ai.rb +30 -0
  34. data/lib/riffer/config/evals.rb +18 -0
  35. data/lib/riffer/config/files.rb +61 -0
  36. data/lib/riffer/config/gemini.rb +21 -0
  37. data/lib/riffer/config/mcp.rb +31 -0
  38. data/lib/riffer/config/open_ai.rb +30 -0
  39. data/lib/riffer/config/open_router.rb +21 -0
  40. data/lib/riffer/config/pricing/rates.rb +36 -0
  41. data/lib/riffer/config/pricing.rb +64 -0
  42. data/lib/riffer/config/skills.rb +38 -0
  43. data/lib/riffer/config/tracing.rb +43 -0
  44. data/lib/riffer/config.rb +3 -362
  45. data/lib/riffer/evals/evaluator.rb +1 -29
  46. data/lib/riffer/evals/evaluator_runner.rb +0 -15
  47. data/lib/riffer/evals/judge.rb +0 -8
  48. data/lib/riffer/evals/result.rb +1 -11
  49. data/lib/riffer/evals/run_result.rb +0 -12
  50. data/lib/riffer/evals/scenario_result.rb +0 -19
  51. data/lib/riffer/files/downloader.rb +2 -3
  52. data/lib/riffer/files/resolver.rb +2 -7
  53. data/lib/riffer/guardrail.rb +2 -22
  54. data/lib/riffer/guardrails/modification.rb +0 -8
  55. data/lib/riffer/guardrails/result.rb +0 -17
  56. data/lib/riffer/guardrails/runner.rb +2 -11
  57. data/lib/riffer/guardrails/tripwire.rb +0 -8
  58. data/lib/riffer/guardrails.rb +0 -2
  59. data/lib/riffer/helpers/boolean.rb +0 -4
  60. data/lib/riffer/helpers/call_or_value.rb +0 -3
  61. data/lib/riffer/helpers/deep_dup.rb +3 -8
  62. data/lib/riffer/helpers/dependencies.rb +0 -4
  63. data/lib/riffer/helpers/identifier.rb +3 -12
  64. data/lib/riffer/helpers/validate.rb +42 -0
  65. data/lib/riffer/mcp/authenticated_tool.rb +4 -12
  66. data/lib/riffer/mcp/client.rb +0 -7
  67. data/lib/riffer/mcp/manifest.rb +3 -7
  68. data/lib/riffer/mcp/registration.rb +0 -10
  69. data/lib/riffer/mcp/registry.rb +0 -9
  70. data/lib/riffer/mcp/search_tool.rb +0 -4
  71. data/lib/riffer/mcp/tool.rb +1 -4
  72. data/lib/riffer/mcp/tool_factory.rb +3 -6
  73. data/lib/riffer/mcp.rb +2 -23
  74. data/lib/riffer/messages/assistant/reasoning_part.rb +4 -21
  75. data/lib/riffer/messages/assistant/tool_call.rb +1 -9
  76. data/lib/riffer/messages/assistant.rb +1 -21
  77. data/lib/riffer/messages/base.rb +0 -12
  78. data/lib/riffer/messages/system.rb +0 -3
  79. data/lib/riffer/messages/tool.rb +0 -15
  80. data/lib/riffer/messages/user/file_part.rb +2 -30
  81. data/lib/riffer/messages/user.rb +0 -4
  82. data/lib/riffer/params/boolean.rb +1 -5
  83. data/lib/riffer/params/param.rb +3 -31
  84. data/lib/riffer/params.rb +8 -41
  85. data/lib/riffer/providers/amazon_bedrock.rb +98 -46
  86. data/lib/riffer/providers/anthropic.rb +65 -65
  87. data/lib/riffer/providers/azure_open_ai.rb +10 -8
  88. data/lib/riffer/providers/base.rb +7 -28
  89. data/lib/riffer/providers/finish_reason.rb +0 -6
  90. data/lib/riffer/providers/gemini/client.rb +4 -17
  91. data/lib/riffer/providers/gemini.rb +4 -10
  92. data/lib/riffer/providers/mock.rb +1 -26
  93. data/lib/riffer/providers/open_ai.rb +94 -46
  94. data/lib/riffer/providers/open_router.rb +105 -30
  95. data/lib/riffer/providers/repository.rb +2 -14
  96. data/lib/riffer/providers/token_usage.rb +5 -14
  97. data/lib/riffer/registrable.rb +11 -45
  98. data/lib/riffer/runner/fibers.rb +1 -6
  99. data/lib/riffer/runner/sequential.rb +0 -1
  100. data/lib/riffer/runner/threaded.rb +0 -3
  101. data/lib/riffer/runner.rb +0 -3
  102. data/lib/riffer/skills/activate_tool.rb +0 -3
  103. data/lib/riffer/skills/adapter.rb +0 -8
  104. data/lib/riffer/skills/backend.rb +2 -7
  105. data/lib/riffer/skills/config.rb +4 -20
  106. data/lib/riffer/skills/context.rb +0 -29
  107. data/lib/riffer/skills/filesystem_backend.rb +0 -7
  108. data/lib/riffer/skills/frontmatter.rb +2 -16
  109. data/lib/riffer/skills/markdown_adapter.rb +3 -6
  110. data/lib/riffer/skills/xml_adapter.rb +0 -3
  111. data/lib/riffer/stream_events/base.rb +0 -3
  112. data/lib/riffer/stream_events/finish_reason_done.rb +1 -6
  113. data/lib/riffer/stream_events/guardrail_modification.rb +0 -10
  114. data/lib/riffer/stream_events/guardrail_tripwire.rb +0 -10
  115. data/lib/riffer/stream_events/interrupt.rb +2 -12
  116. data/lib/riffer/stream_events/reasoning_delta.rb +0 -3
  117. data/lib/riffer/stream_events/reasoning_done.rb +1 -5
  118. data/lib/riffer/stream_events/skill_activation.rb +0 -3
  119. data/lib/riffer/stream_events/text_delta.rb +0 -2
  120. data/lib/riffer/stream_events/text_done.rb +0 -2
  121. data/lib/riffer/stream_events/token_usage_done.rb +0 -2
  122. data/lib/riffer/stream_events/tool_call_delta.rb +1 -5
  123. data/lib/riffer/stream_events/tool_call_done.rb +0 -5
  124. data/lib/riffer/stream_events/web_search_done.rb +0 -3
  125. data/lib/riffer/stream_events/web_search_status.rb +1 -5
  126. data/lib/riffer/testing/minitest.rb +4 -5
  127. data/lib/riffer/testing.rb +5 -38
  128. data/lib/riffer/tool.rb +2 -28
  129. data/lib/riffer/tools/response.rb +3 -32
  130. data/lib/riffer/tools/runtime/fibers.rb +0 -6
  131. data/lib/riffer/tools/runtime/inline.rb +0 -1
  132. data/lib/riffer/tools/runtime/threaded.rb +0 -6
  133. data/lib/riffer/tools/runtime.rb +5 -26
  134. data/lib/riffer/tools/toolable.rb +0 -37
  135. data/lib/riffer/tracing/capture.rb +3 -5
  136. data/lib/riffer/tracing/no_op.rb +0 -7
  137. data/lib/riffer/tracing/otel.rb +7 -16
  138. data/lib/riffer/tracing/stream_recorder.rb +0 -7
  139. data/lib/riffer/tracing.rb +4 -27
  140. data/lib/riffer/version.rb +1 -1
  141. data/lib/riffer.rb +2 -30
  142. data/sig/generated/riffer/agent/config.rbs +12 -48
  143. data/sig/generated/riffer/agent/context.rbs +2 -26
  144. data/sig/generated/riffer/agent/outcome.rbs +0 -20
  145. data/sig/generated/riffer/agent/response.rbs +1 -29
  146. data/sig/generated/riffer/agent/run.rbs +1 -27
  147. data/sig/generated/riffer/agent/serializer.rbs +2 -27
  148. data/sig/generated/riffer/agent/session/repair.rbs +2 -10
  149. data/sig/generated/riffer/agent/session.rbs +10 -36
  150. data/sig/generated/riffer/agent/structured_output/result.rbs +0 -7
  151. data/sig/generated/riffer/agent/structured_output.rbs +0 -7
  152. data/sig/generated/riffer/agent.rbs +5 -121
  153. data/sig/generated/riffer/config/amazon_bedrock.rbs +21 -0
  154. data/sig/generated/riffer/config/anthropic.rbs +15 -0
  155. data/sig/generated/riffer/config/azure_open_ai.rbs +21 -0
  156. data/sig/generated/riffer/config/evals.rbs +13 -0
  157. data/sig/generated/riffer/config/files.rbs +43 -0
  158. data/sig/generated/riffer/config/gemini.rbs +15 -0
  159. data/sig/generated/riffer/config/mcp.rbs +19 -0
  160. data/sig/generated/riffer/config/open_ai.rbs +21 -0
  161. data/sig/generated/riffer/config/open_router.rbs +15 -0
  162. data/sig/generated/riffer/config/pricing/rates.rbs +19 -0
  163. data/sig/generated/riffer/config/pricing.rbs +31 -0
  164. data/sig/generated/riffer/config/skills.rbs +19 -0
  165. data/sig/generated/riffer/config/tracing.rbs +25 -0
  166. data/sig/generated/riffer/config.rbs +2 -307
  167. data/sig/generated/riffer/evals/evaluator.rbs +0 -28
  168. data/sig/generated/riffer/evals/evaluator_runner.rbs +0 -14
  169. data/sig/generated/riffer/evals/judge.rbs +0 -7
  170. data/sig/generated/riffer/evals/result.rbs +1 -11
  171. data/sig/generated/riffer/evals/run_result.rbs +0 -12
  172. data/sig/generated/riffer/evals/scenario_result.rbs +0 -14
  173. data/sig/generated/riffer/files/resolver.rbs +0 -7
  174. data/sig/generated/riffer/guardrail.rbs +2 -22
  175. data/sig/generated/riffer/guardrails/modification.rbs +0 -6
  176. data/sig/generated/riffer/guardrails/result.rbs +0 -15
  177. data/sig/generated/riffer/guardrails/runner.rbs +0 -11
  178. data/sig/generated/riffer/guardrails/tripwire.rbs +0 -8
  179. data/sig/generated/riffer/guardrails.rbs +0 -2
  180. data/sig/generated/riffer/helpers/boolean.rbs +0 -4
  181. data/sig/generated/riffer/helpers/call_or_value.rbs +0 -3
  182. data/sig/generated/riffer/helpers/deep_dup.rbs +0 -8
  183. data/sig/generated/riffer/helpers/dependencies.rbs +0 -4
  184. data/sig/generated/riffer/helpers/identifier.rbs +0 -12
  185. data/sig/generated/riffer/helpers/validate.rbs +21 -0
  186. data/sig/generated/riffer/mcp/authenticated_tool.rbs +0 -5
  187. data/sig/generated/riffer/mcp/client.rbs +0 -7
  188. data/sig/generated/riffer/mcp/manifest.rbs +3 -7
  189. data/sig/generated/riffer/mcp/registration.rbs +0 -10
  190. data/sig/generated/riffer/mcp/registry.rbs +0 -9
  191. data/sig/generated/riffer/mcp/search_tool.rbs +0 -4
  192. data/sig/generated/riffer/mcp/tool.rbs +0 -4
  193. data/sig/generated/riffer/mcp/tool_factory.rbs +0 -6
  194. data/sig/generated/riffer/mcp.rbs +2 -22
  195. data/sig/generated/riffer/messages/assistant/reasoning_part.rbs +4 -17
  196. data/sig/generated/riffer/messages/assistant/tool_call.rbs +1 -9
  197. data/sig/generated/riffer/messages/assistant.rbs +10 -30
  198. data/sig/generated/riffer/messages/base.rbs +0 -12
  199. data/sig/generated/riffer/messages/system.rbs +0 -3
  200. data/sig/generated/riffer/messages/tool.rbs +0 -12
  201. data/sig/generated/riffer/messages/user/file_part.rbs +0 -30
  202. data/sig/generated/riffer/messages/user.rbs +0 -4
  203. data/sig/generated/riffer/params/boolean.rbs +1 -4
  204. data/sig/generated/riffer/params/param.rbs +0 -31
  205. data/sig/generated/riffer/params.rbs +4 -40
  206. data/sig/generated/riffer/providers/amazon_bedrock.rbs +26 -26
  207. data/sig/generated/riffer/providers/anthropic.rbs +23 -22
  208. data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -8
  209. data/sig/generated/riffer/providers/base.rbs +0 -28
  210. data/sig/generated/riffer/providers/finish_reason.rbs +0 -6
  211. data/sig/generated/riffer/providers/gemini/client.rbs +4 -17
  212. data/sig/generated/riffer/providers/gemini.rbs +0 -6
  213. data/sig/generated/riffer/providers/mock.rbs +0 -26
  214. data/sig/generated/riffer/providers/open_ai.rbs +36 -19
  215. data/sig/generated/riffer/providers/open_router.rbs +33 -14
  216. data/sig/generated/riffer/providers/repository.rbs +2 -14
  217. data/sig/generated/riffer/providers/token_usage.rbs +5 -14
  218. data/sig/generated/riffer/registrable.rbs +0 -45
  219. data/sig/generated/riffer/runner/fibers.rbs +0 -6
  220. data/sig/generated/riffer/runner/sequential.rbs +0 -1
  221. data/sig/generated/riffer/runner/threaded.rbs +0 -3
  222. data/sig/generated/riffer/runner.rbs +0 -3
  223. data/sig/generated/riffer/skills/activate_tool.rbs +0 -3
  224. data/sig/generated/riffer/skills/adapter.rbs +0 -8
  225. data/sig/generated/riffer/skills/backend.rbs +2 -7
  226. data/sig/generated/riffer/skills/config.rbs +0 -20
  227. data/sig/generated/riffer/skills/context.rbs +0 -29
  228. data/sig/generated/riffer/skills/filesystem_backend.rbs +0 -7
  229. data/sig/generated/riffer/skills/frontmatter.rbs +2 -16
  230. data/sig/generated/riffer/skills/markdown_adapter.rbs +0 -6
  231. data/sig/generated/riffer/skills/xml_adapter.rbs +0 -3
  232. data/sig/generated/riffer/stream_events/base.rbs +0 -3
  233. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +1 -6
  234. data/sig/generated/riffer/stream_events/guardrail_modification.rbs +0 -10
  235. data/sig/generated/riffer/stream_events/guardrail_tripwire.rbs +0 -10
  236. data/sig/generated/riffer/stream_events/interrupt.rbs +2 -10
  237. data/sig/generated/riffer/stream_events/reasoning_delta.rbs +0 -3
  238. data/sig/generated/riffer/stream_events/reasoning_done.rbs +1 -5
  239. data/sig/generated/riffer/stream_events/skill_activation.rbs +0 -3
  240. data/sig/generated/riffer/stream_events/text_delta.rbs +0 -2
  241. data/sig/generated/riffer/stream_events/text_done.rbs +0 -2
  242. data/sig/generated/riffer/stream_events/token_usage_done.rbs +0 -2
  243. data/sig/generated/riffer/stream_events/tool_call_delta.rbs +1 -5
  244. data/sig/generated/riffer/stream_events/tool_call_done.rbs +0 -5
  245. data/sig/generated/riffer/stream_events/web_search_done.rbs +0 -3
  246. data/sig/generated/riffer/stream_events/web_search_status.rbs +1 -5
  247. data/sig/generated/riffer/testing.rbs +0 -38
  248. data/sig/generated/riffer/tool.rbs +0 -27
  249. data/sig/generated/riffer/tools/response.rbs +3 -29
  250. data/sig/generated/riffer/tools/runtime/fibers.rbs +0 -5
  251. data/sig/generated/riffer/tools/runtime/inline.rbs +0 -1
  252. data/sig/generated/riffer/tools/runtime/threaded.rbs +0 -5
  253. data/sig/generated/riffer/tools/runtime.rbs +2 -24
  254. data/sig/generated/riffer/tools/toolable.rbs +0 -37
  255. data/sig/generated/riffer/tracing/capture.rbs +2 -5
  256. data/sig/generated/riffer/tracing/no_op.rbs +0 -7
  257. data/sig/generated/riffer/tracing/otel.rbs +7 -16
  258. data/sig/generated/riffer/tracing/stream_recorder.rbs +0 -2
  259. data/sig/generated/riffer/tracing.rbs +4 -23
  260. data/sig/generated/riffer.rbs +2 -29
  261. data/sig/manual/riffer/helpers/validate.rbs +5 -0
  262. metadata +30 -1
@@ -1,11 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Azure OpenAI provider for GPT models hosted on Azure. Requires the +openai+
5
- # gem. Credentials resolve from config, then +AZURE_OPENAI_API_KEY+ /
6
- # +AZURE_OPENAI_ENDPOINT+.
7
4
  class Riffer::Providers::AzureOpenAI < Riffer::Providers::OpenAI
8
- # The GenAI semconv well-known provider name.
9
5
  #--
10
6
  #: () -> String
11
7
  def self.semconv_provider_name
@@ -20,15 +16,21 @@ class Riffer::Providers::AzureOpenAI < Riffer::Providers::OpenAI
20
16
  Riffer.config.azure_openai.client
21
17
  end
22
18
 
23
- # Deliberately not compacted: this borrows the OpenAI SDK to talk to Azure, so
24
- # omitting an unset argument would let the SDK fall back to +OPENAI_API_KEY+
25
- # and +OPENAI_BASE_URL+ — sending Azure traffic, and an OpenAI credential, to
26
- # whatever those name. Passing nil raises in the SDK instead.
27
19
  #--
28
20
  #: () -> untyped
29
21
  def build_client
30
22
  api_key = Riffer.config.azure_openai.api_key || ENV.fetch("AZURE_OPENAI_API_KEY", nil)
31
23
  base_url = Riffer.config.azure_openai.endpoint || ENV.fetch("AZURE_OPENAI_ENDPOINT", nil)
24
+ # Pass nils rather than omitting them: an omitted argument lets the SDK fall
25
+ # back to OPENAI_API_KEY / OPENAI_BASE_URL, sending Azure traffic and an
26
+ # OpenAI credential elsewhere. A nil raises in the SDK instead.
32
27
  ::OpenAI::Client.new(api_key: api_key, base_url: base_url)
33
28
  end
29
+
30
+ # Azure resolves and decrypts reasoning items only within the resource that produced them.
31
+ #--
32
+ #: () -> String
33
+ def reasoning_format
34
+ "azure-openai-v1"
35
+ end
34
36
  end
@@ -3,10 +3,6 @@
3
3
 
4
4
  require "json"
5
5
 
6
- # Base class for all LLM providers. A template-method flow: subclasses implement
7
- # the hooks (+build_request_params+, +execute_generate+, +execute_stream+,
8
- # +extract_token_usage+, +extract_content+, +extract_tool_calls+) and the base
9
- # class orchestrates them.
10
6
  class Riffer::Providers::Base
11
7
  # @rbs @current_tools: Array[singleton(Riffer::Tool)]
12
8
  # @rbs @current_model: String?
@@ -15,18 +11,12 @@ class Riffer::Providers::Base
15
11
 
16
12
  WIRE_SEPARATOR = "__" #: String
17
13
 
18
- # Returns the preferred skill adapter for this provider; override in
19
- # subclasses (optionally introspecting +model+) for provider-specific formats.
20
14
  #--
21
15
  #: (?String?) -> singleton(Riffer::Skills::Adapter)
22
16
  def self.skills_adapter(_model = nil)
23
17
  Riffer::Skills::MarkdownAdapter
24
18
  end
25
19
 
26
- # Returns the provider name stamped as <tt>gen_ai.provider.name</tt> on trace
27
- # spans, ideally a GenAI semconv well-known value. Defaults to the snake_cased
28
- # class name rather than raising like the abstract provider methods, so
29
- # enabling tracing never breaks an otherwise-working custom provider.
30
20
  #--
31
21
  #: () -> String
32
22
  def self.semconv_provider_name
@@ -34,11 +24,11 @@ class Riffer::Providers::Base
34
24
  # later must pick up its real name, not a frozen "unknown".
35
25
  class_name = name or return "unknown"
36
26
 
27
+ # A default rather than NotImplementedError, so enabling tracing never
28
+ # breaks an otherwise-working custom provider.
37
29
  @semconv_provider_name ||= Riffer::Helpers::Identifier.derive(class_name.split("::").last)
38
30
  end
39
31
 
40
- # Generates text using the provider.
41
- #
42
32
  #--
43
33
  #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?, **untyped) -> Riffer::Messages::Assistant
44
34
  def generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
@@ -77,8 +67,6 @@ class Riffer::Providers::Base
77
67
  end
78
68
  end
79
69
 
80
- # Streams text from the provider.
81
- #
82
70
  #--
83
71
  #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
84
72
  def stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
@@ -119,21 +107,17 @@ class Riffer::Providers::Base
119
107
  Riffer::Helpers::Dependencies.depends_on(gem_name)
120
108
  end
121
109
 
122
- # Returns the client for the current LLM call. A configured client wins,
123
- # resolved on every call so a Proc can vary the client by process or
124
- # credential lifetime; otherwise the provider builds one from the configured
125
- # credentials, memoized for the life of the provider.
126
110
  #--
127
111
  #: () -> untyped
128
112
  def client
129
113
  configured = global_client
114
+ # Resolved on every call, never memoized, so a Proc can vary the client by
115
+ # process or credential lifetime.
130
116
  return Riffer::Helpers::CallOrValue.resolve(configured) if configured
131
117
 
132
118
  @client ||= build_client
133
119
  end
134
120
 
135
- # Returns the consumer-configured client for this provider; nil when none is
136
- # configured, and for providers that take no configuration at all.
137
121
  #--
138
122
  #: () -> untyped
139
123
  def global_client
@@ -218,19 +202,17 @@ class Riffer::Providers::Base
218
202
  pricing.rates_for("#{key}/#{model}")
219
203
  end
220
204
 
221
- # Defaults to nil rather than raising — finish reasons are optional, so
222
- # providers that don't report one stay valid.
223
205
  #--
224
206
  #: (untyped) -> Riffer::Providers::FinishReason?
225
207
  def extract_finish_reason(_response)
208
+ # Optional hook, so providers that don't report a finish reason stay valid.
226
209
  nil
227
210
  end
228
211
 
229
- # Defaults to no parts rather than raising — reasoning parts are optional, so
230
- # providers that don't expose replayable reasoning stay valid.
231
212
  #--
232
213
  #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
233
214
  def extract_reasoning(_response)
215
+ # Optional hook, so providers that don't expose replayable reasoning stay valid.
234
216
  []
235
217
  end
236
218
 
@@ -295,8 +277,6 @@ class Riffer::Providers::Base
295
277
  attributes.merge(tag_attributes(options[:tags] || {}))
296
278
  end
297
279
 
298
- # Maps normalized tags to their namespaced span attribute form. An empty map
299
- # yields an empty hash, so merging it is a no-op.
300
280
  #--
301
281
  #: (Hash[String, String]) -> Hash[String, String]
302
282
  def tag_attributes(tags)
@@ -358,11 +338,10 @@ class Riffer::Providers::Base
358
338
  Riffer.config.tracing.capture_messages && span.recording?
359
339
  end
360
340
 
361
- # Wraps reasoning text that an adapter cannot yet replay in a +:text+ part
362
- # with no +format+, so it persists for display but is never sent back.
363
341
  #--
364
342
  #: (Riffer::Providers::_EventSink, String) -> void
365
343
  def yield_reasoning_done(yielder, text)
344
+ # No +format+, so the reasoning persists for display but is never replayed.
366
345
  part = Riffer::Messages::Assistant::ReasoningPart.new(type: :text, text: text)
367
346
  yielder << Riffer::StreamEvents::ReasoningDone.new(part)
368
347
  end
@@ -1,19 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Normalized reason an LLM call finished, paired with the provider's raw
5
- # wire value. +reason+ carries the same meaning for every provider.
6
4
  class Riffer::Providers::FinishReason
7
- # The normalized vocabulary every provider maps into.
8
5
  VALUES = %i[stop length tool_calls content_filter context_window malformed_output error other].freeze #: Array[Symbol]
9
6
 
10
- # The normalized reason.
11
7
  attr_reader :reason #: Symbol # @dynamic reason
12
8
 
13
- # The provider's raw finish-reason value, when one exists on the wire.
14
9
  attr_reader :raw #: String? # @dynamic raw
15
10
 
16
- # Raises Riffer::ArgumentError when +reason+ is outside VALUES.
17
11
  #--
18
12
  #: (reason: Symbol, ?raw: String?) -> void
19
13
  def initialize(reason:, raw: nil)
@@ -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)
@@ -181,16 +173,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
181
173
  #: (untyped) -> String
182
174
  def extract_content(response)
183
175
  typed_response = response #: OpenAI::Models::Responses::Response
184
- text_content = ""
185
176
 
186
- typed_response.output.each do |item|
187
- next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
188
-
189
- text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
190
- text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
191
- end
177
+ typed_response.output.flat_map do |item|
178
+ next [] unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
192
179
 
193
- text_content
180
+ item.content.filter_map { |c| c.text if c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
181
+ end.join
194
182
  end
195
183
 
196
184
  #--
@@ -212,10 +200,47 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
212
200
  tool_calls
213
201
  end
214
202
 
203
+ #--
204
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
205
+ def extract_reasoning(response)
206
+ typed_response = response #: OpenAI::Models::Responses::Response
207
+ typed_response.output.
208
+ grep(::OpenAI::Models::Responses::ResponseReasoningItem).
209
+ flat_map { |item| build_reasoning_parts(item) }
210
+ end
211
+
212
+ # The trailing +:encrypted+ part is always present, even without a payload, so the item's id
213
+ # survives for replay: with +store+ on, OpenAI resolves the item from the id alone.
214
+ #--
215
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
216
+ def build_reasoning_parts(item)
217
+ typed_item = item #: OpenAI::Models::Responses::ResponseReasoningItem
218
+ id = typed_item.id
219
+ summaries = typed_item.summary.map { |summary| build_reasoning_part(type: :summary, id: id, text: summary.text) }
220
+ texts = (typed_item.content || []).map { |content| build_reasoning_part(type: :text, id: id, text: content.text) }
221
+
222
+ [*summaries, *texts, build_reasoning_part(type: :encrypted, id: id, data: typed_item.encrypted_content)]
223
+ end
224
+
225
+ #--
226
+ #: (type: Symbol, id: String, ?text: String?, ?data: String?) -> Riffer::Messages::Assistant::ReasoningPart
227
+ def build_reasoning_part(type:, id:, text: nil, data: nil)
228
+ Riffer::Messages::Assistant::ReasoningPart.new(type: type, text: text, data: data, id: id, format: reasoning_format)
229
+ end
230
+
231
+ # Distinct from OpenRouter's +openai-responses-v1+: reasoning relayed through OpenRouter is
232
+ # encrypted and stored under OpenRouter's organization, so neither side can replay the other's.
233
+ #--
234
+ #: () -> String
235
+ def reasoning_format
236
+ "openai-v1"
237
+ end
238
+
215
239
  #--
216
240
  #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
217
241
  def execute_stream(params, yielder)
218
242
  current_state = {
243
+ text: nil,
219
244
  tool_info: {},
220
245
  } #: Hash[Symbol, untyped]
221
246
 
@@ -231,12 +256,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
231
256
  end
232
257
  when :"response.output_text.delta"
233
258
  handle_output_text_delta(event, state: current_state, yielder: yielder)
234
- when :"response.output_text.done"
235
- handle_output_text_done(event, state: current_state, yielder: yielder)
236
259
  when :"response.reasoning_summary_text.delta"
237
260
  handle_reasoning_summary_text_delta(event, state: current_state, yielder: yielder)
238
- when :"response.reasoning_summary_text.done"
239
- handle_reasoning_summary_text_done(event, state: current_state, yielder: yielder)
240
261
  when :"response.function_call_arguments.delta"
241
262
  handle_function_call_arguments_delta(event, state: current_state, yielder: yielder)
242
263
  when :"response.function_call_arguments.done"
@@ -248,16 +269,18 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
248
269
  when :"response.web_search_call.completed"
249
270
  handle_web_search_status(event, status: "completed", yielder: yielder)
250
271
  when :"response.output_item.done"
251
- handle_output_item_done_web_search(event, yielder: yielder) if event.item&.type == :web_search_call
272
+ case event.item&.type
273
+ when :web_search_call then handle_output_item_done_web_search(event, yielder: yielder)
274
+ when :reasoning then handle_output_item_done_reasoning(event, yielder: yielder)
275
+ end
252
276
  when :"response.completed", :"response.incomplete", :"response.failed"
253
277
  stream_completed = true
254
278
  handle_response_finished(event, state: current_state, yielder: yielder)
255
279
  end
256
280
  end
257
281
  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.
282
+ # The SDK doesn't close the SSE socket when iteration is interrupted (raise / fiber
283
+ # cancellation), so it leaks until GC. close is idempotent and a no-op after EOF.
261
284
  stream.close
262
285
  end
263
286
 
@@ -279,27 +302,19 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
279
302
  #--
280
303
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
281
304
  def handle_output_text_delta(event, state:, yielder:)
305
+ # << avoids += copying the whole buffer per delta (O(n^2)); safe because
306
+ # nothing reads the buffer before the response finishes and TextDone takes it.
307
+ state[:text] ||= +""
308
+ state[:text] << event.delta
282
309
  yielder << Riffer::StreamEvents::TextDelta.new(event.delta)
283
310
  end
284
311
 
285
- #--
286
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
287
- def handle_output_text_done(event, state:, yielder:)
288
- yielder << Riffer::StreamEvents::TextDone.new(event.text)
289
- end
290
-
291
312
  #--
292
313
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
293
314
  def handle_reasoning_summary_text_delta(event, state:, yielder:)
294
315
  yielder << Riffer::StreamEvents::ReasoningDelta.new(event.delta)
295
316
  end
296
317
 
297
- #--
298
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
299
- def handle_reasoning_summary_text_done(event, state:, yielder:)
300
- yield_reasoning_done(yielder, event.text)
301
- end
302
-
303
318
  #--
304
319
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
305
320
  def handle_function_call_arguments_delta(event, state:, yielder:)
@@ -326,6 +341,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
326
341
  #--
327
342
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
328
343
  def handle_response_finished(event, state:, yielder:)
344
+ yielder << Riffer::StreamEvents::TextDone.new(state[:text]) if state[:text]
345
+
329
346
  response = event.response
330
347
  return unless response
331
348
 
@@ -349,8 +366,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
349
366
  action = event.item.action
350
367
  case action
351
368
  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.
369
+ # OpenPage carries a url but no query or sources, so it doesn't fit WebSearchDone.
354
370
  yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
355
371
  when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
356
372
  sources = (action.sources || []).map { |s| { title: nil, url: s.url } }
@@ -358,6 +374,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
358
374
  end
359
375
  end
360
376
 
377
+ #--
378
+ #: (untyped, yielder: Riffer::Providers::_EventSink) -> void
379
+ def handle_output_item_done_reasoning(event, yielder:)
380
+ build_reasoning_parts(event.item).each { |part| yielder << Riffer::StreamEvents::ReasoningDone.new(part) }
381
+ end
382
+
361
383
  #--
362
384
  #: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
363
385
  def convert_messages_to_openai_format(messages)
@@ -388,12 +410,13 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
388
410
  end
389
411
 
390
412
  #--
391
- #: (Riffer::Messages::Assistant) -> (Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]])
413
+ #: (Riffer::Messages::Assistant) -> Array[Hash[Symbol, untyped]]
392
414
  def convert_assistant_to_openai_format(message)
415
+ items = convert_reasoning_to_openai_format(message.reasoning)
416
+
393
417
  if message.tool_calls.empty?
394
- { role: "assistant", content: message.content }
418
+ items << { role: "assistant", content: message.content }
395
419
  else
396
- items = [] #: Array[Hash[Symbol, untyped]]
397
420
  if message.content && !message.content.empty?
398
421
  items << { type: "message", role: "assistant",
399
422
  content: message.content, }
@@ -406,8 +429,33 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
406
429
  arguments: tc.arguments,
407
430
  }
408
431
  end
409
- items
410
432
  end
433
+
434
+ items
435
+ end
436
+
437
+ #--
438
+ #: (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Array[Hash[Symbol, untyped]]
439
+ def convert_reasoning_to_openai_format(reasoning)
440
+ reasoning.
441
+ select { |part| part.format == reasoning_format }.
442
+ chunk_while { |a, b| a.id == b.id }.
443
+ map { |parts| convert_reasoning_item_to_openai_format(parts) }
444
+ end
445
+
446
+ #--
447
+ #: (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Hash[Symbol, untyped]
448
+ def convert_reasoning_item_to_openai_format(parts)
449
+ summary = parts.filter_map { |part| { type: "summary_text", text: part.text } if part.type == :summary }
450
+ content = parts.filter_map { |part| { type: "reasoning_text", text: part.text } if part.type == :text }
451
+
452
+ {
453
+ type: "reasoning",
454
+ id: parts.first&.id,
455
+ summary: summary,
456
+ content: content.empty? ? nil : content,
457
+ encrypted_content: parts.find { |part| part.type == :encrypted }&.data,
458
+ }.compact
411
459
  end
412
460
 
413
461
  #--