riffer 0.28.0 → 0.29.1

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/architecture.md +18 -11
  3. data/.agents/code-style.md +1 -1
  4. data/.agents/rbs-inline.md +53 -2
  5. data/.agents/testing.md +9 -5
  6. data/.release-please-manifest.json +1 -1
  7. data/AGENTS.md +17 -10
  8. data/CHANGELOG.md +26 -0
  9. data/README.md +17 -18
  10. data/Steepfile +8 -1
  11. data/docs/03_AGENTS.md +34 -3
  12. data/docs/04_AGENT_LIFECYCLE.md +87 -86
  13. data/docs/05_AGENT_LOOP.md +2 -2
  14. data/docs/06_TOOLS.md +9 -4
  15. data/docs/07_TOOL_ADVANCED.md +17 -17
  16. data/docs/08_MESSAGES.md +25 -32
  17. data/docs/09_STREAM_EVENTS.md +1 -1
  18. data/docs/10_CONFIGURATION.md +7 -18
  19. data/docs/providers/01_PROVIDERS.md +6 -0
  20. data/docs/providers/06_MOCK_PROVIDER.md +2 -1
  21. data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
  22. data/docs/providers/08_GEMINI.md +2 -2
  23. data/docs/providers/09_OPENROUTER.md +242 -0
  24. data/lib/riffer/agent/config.rb +173 -0
  25. data/lib/riffer/agent/context.rb +127 -0
  26. data/lib/riffer/agent/response.rb +2 -0
  27. data/lib/riffer/agent/run.rb +308 -0
  28. data/lib/riffer/agent/session/repair.rb +112 -0
  29. data/lib/riffer/agent/session.rb +270 -0
  30. data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
  31. data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
  32. data/lib/riffer/agent.rb +236 -923
  33. data/lib/riffer/config.rb +14 -7
  34. data/lib/riffer/evals/evaluator.rb +18 -3
  35. data/lib/riffer/evals/judge.rb +7 -2
  36. data/lib/riffer/evals/run_result.rb +2 -1
  37. data/lib/riffer/evals/scenario_result.rb +2 -1
  38. data/lib/riffer/guardrails/runner.rb +3 -2
  39. data/lib/riffer/helpers/call_or_value.rb +16 -0
  40. data/lib/riffer/helpers.rb +0 -1
  41. data/lib/riffer/mcp/authenticated_tool.rb +4 -0
  42. data/lib/riffer/mcp/client.rb +3 -1
  43. data/lib/riffer/mcp/registration.rb +6 -3
  44. data/lib/riffer/mcp/registry.rb +6 -1
  45. data/lib/riffer/mcp/tool_factory.rb +5 -0
  46. data/lib/riffer/messages/assistant.rb +9 -3
  47. data/lib/riffer/messages/base.rb +22 -0
  48. data/lib/riffer/messages/converter.rb +6 -6
  49. data/lib/riffer/{file_part.rb → messages/file_part.rb} +7 -5
  50. data/lib/riffer/messages/tool.rb +1 -1
  51. data/lib/riffer/messages/user.rb +4 -4
  52. data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
  53. data/lib/riffer/{param.rb → params/param.rb} +6 -6
  54. data/lib/riffer/params.rb +27 -21
  55. data/lib/riffer/providers/amazon_bedrock.rb +37 -31
  56. data/lib/riffer/providers/anthropic.rb +39 -36
  57. data/lib/riffer/providers/base.rb +12 -9
  58. data/lib/riffer/providers/gemini.rb +19 -12
  59. data/lib/riffer/providers/mock.rb +45 -13
  60. data/lib/riffer/providers/open_ai.rb +34 -29
  61. data/lib/riffer/providers/open_router.rb +325 -0
  62. data/lib/riffer/providers/repository.rb +1 -0
  63. data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
  64. data/lib/riffer/providers.rb +1 -0
  65. data/lib/riffer/runner/fibers.rb +6 -3
  66. data/lib/riffer/runner/sequential.rb +1 -1
  67. data/lib/riffer/runner/threaded.rb +3 -1
  68. data/lib/riffer/runner.rb +1 -1
  69. data/lib/riffer/skills/activate_tool.rb +4 -3
  70. data/lib/riffer/skills/config.rb +6 -1
  71. data/lib/riffer/skills/context.rb +6 -3
  72. data/lib/riffer/skills/filesystem_backend.rb +10 -5
  73. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  74. data/lib/riffer/skills/xml_adapter.rb +1 -1
  75. data/lib/riffer/stream_events/interrupt.rb +1 -1
  76. data/lib/riffer/stream_events/token_usage_done.rb +2 -2
  77. data/lib/riffer/stream_events/web_search_status.rb +1 -1
  78. data/lib/riffer/tool.rb +3 -3
  79. data/lib/riffer/tools/response.rb +2 -0
  80. data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  81. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  82. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  83. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +11 -9
  84. data/lib/riffer/{toolable.rb → tools/toolable.rb} +19 -9
  85. data/lib/riffer/version.rb +1 -1
  86. data/lib/riffer.rb +4 -1
  87. data/sig/_private/anthropic.rbs +16 -0
  88. data/sig/_private/async.rbs +24 -0
  89. data/sig/_private/aws-sdk-core/seahorse_request_context.rbs +7 -0
  90. data/sig/_private/aws-sdk-core/static_token_provider.rbs +5 -0
  91. data/sig/_private/mcp.rbs +22 -0
  92. data/sig/_private/openai.rbs +29 -0
  93. data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -0
  94. data/sig/_private/riffer/providers/anthropic.rbs +4 -0
  95. data/sig/_private/riffer/providers/open_ai.rbs +4 -0
  96. data/sig/_private/riffer/providers/open_router.rbs +4 -0
  97. data/sig/_private/zeitwerk.rbs +12 -0
  98. data/sig/generated/riffer/agent/config.rbs +119 -0
  99. data/sig/generated/riffer/agent/context.rbs +93 -0
  100. data/sig/generated/riffer/agent/response.rbs +2 -0
  101. data/sig/generated/riffer/agent/run.rbs +144 -0
  102. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  103. data/sig/generated/riffer/agent/session.rbs +147 -0
  104. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  105. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  106. data/sig/generated/riffer/agent.rbs +145 -342
  107. data/sig/generated/riffer/config.rbs +17 -5
  108. data/sig/generated/riffer/evals/evaluator.rbs +8 -0
  109. data/sig/generated/riffer/evals/judge.rbs +10 -2
  110. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  111. data/sig/generated/riffer/helpers.rbs +0 -1
  112. data/sig/generated/riffer/mcp/client.rbs +2 -0
  113. data/sig/generated/riffer/mcp/registration.rbs +6 -0
  114. data/sig/generated/riffer/mcp/registry.rbs +4 -0
  115. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  116. data/sig/generated/riffer/messages/base.rbs +18 -0
  117. data/sig/generated/riffer/messages/converter.rbs +4 -4
  118. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +7 -5
  119. data/sig/generated/riffer/messages/user.rbs +4 -4
  120. data/sig/generated/riffer/params/boolean.rbs +10 -0
  121. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  122. data/sig/generated/riffer/params.rbs +15 -15
  123. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  124. data/sig/generated/riffer/providers/anthropic.rbs +12 -12
  125. data/sig/generated/riffer/providers/base.rbs +12 -10
  126. data/sig/generated/riffer/providers/gemini.rbs +10 -4
  127. data/sig/generated/riffer/providers/mock.rbs +31 -5
  128. data/sig/generated/riffer/providers/open_ai.rbs +10 -10
  129. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  130. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  131. data/sig/generated/riffer/providers.rbs +1 -0
  132. data/sig/generated/riffer/runner/fibers.rbs +4 -2
  133. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  134. data/sig/generated/riffer/runner/threaded.rbs +4 -2
  135. data/sig/generated/riffer/runner.rbs +2 -2
  136. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  137. data/sig/generated/riffer/skills/config.rbs +9 -1
  138. data/sig/generated/riffer/skills/context.rbs +6 -2
  139. data/sig/generated/riffer/skills/filesystem_backend.rbs +4 -0
  140. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  141. data/sig/generated/riffer/tool.rbs +5 -5
  142. data/sig/generated/riffer/tools/response.rbs +2 -0
  143. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  144. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  145. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  146. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +14 -12
  147. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +18 -6
  148. data/sig/generated/riffer.rbs +2 -0
  149. data/sig/manifest.yaml +3 -0
  150. data/sig/manual/riffer/agent/run.rbs +5 -0
  151. data/sig/manual/riffer/helpers/call_or_value.rbs +5 -0
  152. data/sig/manual/riffer/tools/toolable.rbs +6 -0
  153. metadata +59 -33
  154. data/lib/riffer/core.rb +0 -28
  155. data/lib/riffer/helpers/validations.rb +0 -18
  156. data/sig/generated/riffer/boolean.rbs +0 -10
  157. data/sig/generated/riffer/core.rbs +0 -19
  158. data/sig/generated/riffer/helpers/validations.rbs +0 -12
@@ -8,6 +8,10 @@ require "uri"
8
8
 
9
9
  # Google Gemini provider for Gemini models via the Gemini REST API.
10
10
  class Riffer::Providers::Gemini < Riffer::Providers::Base
11
+ # @rbs @api_key: String?
12
+ # @rbs @open_timeout: Integer
13
+ # @rbs @read_timeout: Integer
14
+
11
15
  BASE_URI = URI("https://generativelanguage.googleapis.com") #: URI::Generic
12
16
  VALID_MODEL_PATTERN = /\A[a-zA-Z0-9._-]+\z/ #: Regexp
13
17
  DEFAULT_OPEN_TIMEOUT = 10 #: Integer
@@ -36,7 +40,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
36
40
  params = {
37
41
  model: model,
38
42
  contents: partitioned[:contents]
39
- }
43
+ } #: Hash[Symbol, untyped]
40
44
 
41
45
  params[:systemInstruction] = partitioned[:system_instruction] if partitioned[:system_instruction]
42
46
 
@@ -96,12 +100,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
96
100
  end
97
101
 
98
102
  #--
99
- #: (Hash[Symbol, untyped]) -> Riffer::TokenUsage?
103
+ #: (Hash[Symbol, untyped]) -> Riffer::Providers::TokenUsage?
100
104
  def extract_token_usage(response)
101
105
  usage = response[:usageMetadata]
102
106
  return nil unless usage
103
107
 
104
- Riffer::TokenUsage.new(
108
+ Riffer::Providers::TokenUsage.new(
105
109
  input_tokens: usage[:promptTokenCount] || 0,
106
110
  output_tokens: usage[:candidatesTokenCount] || 0
107
111
  )
@@ -114,6 +118,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
114
118
  body = params.except(:model)
115
119
 
116
120
  uri = URI("#{BASE_URI}/#{api_path(model, "streamGenerateContent")}?alt=sse")
121
+ host = uri.hostname #: String
117
122
  request = Net::HTTP::Post.new(uri)
118
123
  request["Content-Type"] = "application/json"
119
124
  request["x-goog-api-key"] = @api_key
@@ -126,7 +131,8 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
126
131
  buffer << chunk
127
132
 
128
133
  while (match = buffer.match(/\r?\n\r?\n/))
129
- frame = buffer.slice!(0, match.end(0)).strip
134
+ match_end = match.end(0) #: Integer
135
+ frame = buffer.slice!(0, match_end).to_s.strip
130
136
  next unless frame.start_with?("data: ")
131
137
 
132
138
  json_str = frame.delete_prefix("data: ").strip
@@ -155,7 +161,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
155
161
  usage = parsed[:usageMetadata]
156
162
  if usage && usage[:candidatesTokenCount]
157
163
  yielder << Riffer::StreamEvents::TokenUsageDone.new(
158
- token_usage: Riffer::TokenUsage.new(
164
+ token_usage: Riffer::Providers::TokenUsage.new(
159
165
  input_tokens: usage[:promptTokenCount] || 0,
160
166
  output_tokens: usage[:candidatesTokenCount] || 0
161
167
  )
@@ -164,7 +170,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
164
170
  end
165
171
  end
166
172
 
167
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
173
+ Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
168
174
  http.request(request) do |response|
169
175
  handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
170
176
 
@@ -182,8 +188,8 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
182
188
  #--
183
189
  #: (Array[Riffer::Messages::Base]) -> Hash[Symbol, untyped]
184
190
  def partition_messages(messages)
185
- system_parts = []
186
- contents = []
191
+ system_parts = [] #: Array[Hash[Symbol, untyped]]
192
+ contents = [] #: Array[Hash[Symbol, untyped]]
187
193
 
188
194
  messages.each do |message|
189
195
  case message
@@ -212,7 +218,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
212
218
  end
213
219
  end
214
220
 
215
- result = {contents: contents}
221
+ result = {contents: contents} #: Hash[Symbol, untyped]
216
222
  result[:system_instruction] = {parts: system_parts} unless system_parts.empty?
217
223
  result
218
224
  end
@@ -220,7 +226,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
220
226
  #--
221
227
  #: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
222
228
  def convert_assistant_to_gemini_format(message)
223
- parts = []
229
+ parts = [] #: Array[Hash[Symbol, untyped]]
224
230
  parts << {text: message.content} if message.content && !message.content.empty?
225
231
 
226
232
  message.tool_calls.each do |tc|
@@ -236,7 +242,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
236
242
  end
237
243
 
238
244
  #--
239
- #: (Riffer::FilePart) -> Hash[Symbol, untyped]
245
+ #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
240
246
  def convert_file_part_to_gemini_format(file)
241
247
  if file.url?
242
248
  raise Riffer::ArgumentError,
@@ -268,11 +274,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
268
274
  #: (String, Hash[Symbol, untyped]) -> Net::HTTPResponse
269
275
  def post_request(path, body)
270
276
  uri = URI("#{BASE_URI}/#{path}")
277
+ host = uri.hostname #: String
271
278
  request = Net::HTTP::Post.new(uri)
272
279
  request["Content-Type"] = "application/json"
273
280
  request["x-goog-api-key"] = @api_key
274
281
  request.body = body.to_json
275
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) { |http| http.request(request) }
282
+ Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) { |http| http.request(request) }
276
283
  end
277
284
 
278
285
  #--
@@ -5,6 +5,10 @@
5
5
  #
6
6
  # No external gems required.
7
7
  class Riffer::Providers::Mock < Riffer::Providers::Base
8
+ # @rbs @responses: Array[Hash[Symbol, untyped]]
9
+ # @rbs @current_index: Integer
10
+ # @rbs @stubbed_responses: Array[Hash[Symbol, untyped]]
11
+
8
12
  # Returns the preferred skill adapter for the given mock model.
9
13
  #
10
14
  # Mock is used to stand in for any real provider in tests, so the model
@@ -25,10 +29,21 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
25
29
 
26
30
  # Initializes the mock provider.
27
31
  #
32
+ # +responses:+ accepts an array of response hashes in the same shape
33
+ # +#stub_response+ takes — raw +tool_calls:+ hashes are normalised to
34
+ # +Riffer::Messages::Assistant::ToolCall+ instances. This is the canonical
35
+ # way to pre-configure canned LLM responses on an agent via
36
+ # +provider_options responses: [...]+.
37
+ #
38
+ # Riffer::Providers::Mock.new(responses: [
39
+ # {content: "", tool_calls: [{name: "tool_a", arguments: "{}"}]},
40
+ # {content: "Final answer"}
41
+ # ])
42
+ #
28
43
  #--
29
44
  #: (**untyped) -> void
30
45
  def initialize(**options)
31
- @responses = options[:responses] || []
46
+ @responses = (options[:responses] || []).map { |r| normalize_response(r) }
32
47
  @current_index = 0
33
48
  @calls = []
34
49
  @stubbed_responses = []
@@ -40,19 +55,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
40
55
  #
41
56
  # provider.stub_response("Hello")
42
57
  # provider.stub_response("", tool_calls: [{name: "my_tool", arguments: '{"key":"value"}'}])
43
- # provider.stub_response("Final response", token_usage: Riffer::TokenUsage.new(input_tokens: 10, output_tokens: 5))
58
+ # provider.stub_response("Final response", token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
44
59
  #
45
60
  #--
46
- #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::TokenUsage?) -> void
61
+ #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?) -> void
47
62
  def stub_response(content, tool_calls: [], token_usage: nil)
48
- formatted_tool_calls = tool_calls.map.with_index do |tc, idx|
49
- Riffer::Messages::Assistant::ToolCall.new(
50
- call_id: tc[:call_id] || tc[:id] || "mock_call_#{idx}",
51
- name: tc[:name],
52
- arguments: tc[:arguments].is_a?(String) ? tc[:arguments] : tc[:arguments].to_json
53
- )
54
- end
55
- @stubbed_responses << {role: "assistant", content: content, tool_calls: formatted_tool_calls, token_usage: token_usage}
63
+ @stubbed_responses << normalize_response(content: content, tool_calls: tool_calls, token_usage: token_usage)
56
64
  end
57
65
 
58
66
  # Clears all stubbed responses.
@@ -65,6 +73,30 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
65
73
 
66
74
  private
67
75
 
76
+ # Normalises a response hash into Mock's internal format. Accepts the
77
+ # +#stub_response+ kwargs shape (+content:+, +tool_calls:+, +token_usage:+)
78
+ # or a pre-built hash with already-converted ToolCall instances. Raw
79
+ # +tool_calls:+ hashes are wrapped in +Riffer::Messages::Assistant::ToolCall+.
80
+ #
81
+ #--
82
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
83
+ def normalize_response(response)
84
+ formatted_tool_calls = (response[:tool_calls] || []).map.with_index do |tc, idx|
85
+ next tc if tc.is_a?(Riffer::Messages::Assistant::ToolCall)
86
+ Riffer::Messages::Assistant::ToolCall.new(
87
+ call_id: tc[:call_id] || tc[:id] || "mock_call_#{idx}",
88
+ name: tc[:name],
89
+ arguments: tc[:arguments].is_a?(String) ? tc[:arguments] : tc[:arguments].to_json
90
+ )
91
+ end
92
+ {
93
+ role: response[:role] || "assistant",
94
+ content: response[:content] || "",
95
+ tool_calls: formatted_tool_calls,
96
+ token_usage: response[:token_usage]
97
+ }
98
+ end
99
+
68
100
  #--
69
101
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
70
102
  def build_request_params(messages, model, options)
@@ -82,7 +114,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
82
114
  end
83
115
 
84
116
  #--
85
- #: (untyped) -> Riffer::TokenUsage?
117
+ #: (untyped) -> Riffer::Providers::TokenUsage?
86
118
  def extract_token_usage(response)
87
119
  response[:token_usage]
88
120
  end
@@ -146,7 +178,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
146
178
  def next_response
147
179
  if @stubbed_responses.any?
148
180
  @stubbed_responses.shift
149
- elsif @responses[@current_index]
181
+ elsif @current_index < @responses.size
150
182
  response = @responses[@current_index]
151
183
  @current_index += 1
152
184
  response
@@ -36,9 +36,9 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
36
36
  summary: "auto"
37
37
  },
38
38
  **options.except(:reasoning, :tools, :structured_output, :web_search)
39
- }
39
+ } #: Hash[Symbol, untyped]
40
40
 
41
- openai_tools = []
41
+ openai_tools = [] #: Array[Hash[Symbol, untyped]]
42
42
  openai_tools.concat(tools.map { |t| convert_tool_to_openai_format(t) }) if tools && !tools.empty?
43
43
 
44
44
  if web_search
@@ -66,51 +66,54 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
66
66
  end
67
67
 
68
68
  #--
69
- #: (Hash[Symbol, untyped]) -> OpenAI::Models::Responses::Response
69
+ #: (Hash[Symbol, untyped]) -> untyped
70
70
  def execute_generate(params)
71
71
  @client.responses.create(params)
72
72
  end
73
73
 
74
74
  #--
75
- #: (OpenAI::Models::Responses::Response) -> Riffer::TokenUsage?
75
+ #: (untyped) -> Riffer::Providers::TokenUsage?
76
76
  def extract_token_usage(response)
77
- usage = response.usage
77
+ typed_response = response #: OpenAI::Models::Responses::Response
78
+ usage = typed_response.usage
78
79
  return nil unless usage
79
80
 
80
- Riffer::TokenUsage.new(
81
+ Riffer::Providers::TokenUsage.new(
81
82
  input_tokens: usage.input_tokens,
82
83
  output_tokens: usage.output_tokens
83
84
  )
84
85
  end
85
86
 
86
87
  #--
87
- #: (OpenAI::Models::Responses::Response) -> String
88
+ #: (untyped) -> String
88
89
  def extract_content(response)
90
+ typed_response = response #: OpenAI::Models::Responses::Response
89
91
  text_content = ""
90
92
 
91
- response.output.each do |item|
92
- if item.type == :message
93
- text_block = item.content&.find { |c| c.type == :output_text }
94
- text_content = text_block&.text || "" if text_block
95
- end
93
+ typed_response.output.each do |item|
94
+ next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
95
+
96
+ text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
97
+ text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
96
98
  end
97
99
 
98
100
  text_content
99
101
  end
100
102
 
101
103
  #--
102
- #: (OpenAI::Models::Responses::Response) -> Array[Riffer::Messages::Assistant::ToolCall]
104
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
103
105
  def extract_tool_calls(response)
104
- tool_calls = []
105
-
106
- response.output.each do |item|
107
- if item.type == :function_call
108
- tool_calls << Riffer::Messages::Assistant::ToolCall.new(
109
- call_id: item.call_id,
110
- name: decode_tool_name(item.name, tools: @current_tools),
111
- arguments: item.arguments
112
- )
113
- end
106
+ typed_response = response #: OpenAI::Models::Responses::Response
107
+ tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
108
+
109
+ typed_response.output.each do |item|
110
+ next unless item.is_a?(::OpenAI::Models::Responses::ResponseFunctionToolCall)
111
+
112
+ tool_calls << Riffer::Messages::Assistant::ToolCall.new(
113
+ call_id: item.call_id,
114
+ name: decode_tool_name(item.name, tools: @current_tools),
115
+ arguments: item.arguments
116
+ )
114
117
  end
115
118
 
116
119
  tool_calls
@@ -121,7 +124,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
121
124
  def execute_stream(params, yielder)
122
125
  current_state = {
123
126
  tool_info: {}
124
- }
127
+ } #: Hash[Symbol, untyped]
125
128
 
126
129
  stream = @client.responses.stream(params)
127
130
  begin
@@ -224,7 +227,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
224
227
  return unless usage
225
228
 
226
229
  yielder << Riffer::StreamEvents::TokenUsageDone.new(
227
- token_usage: Riffer::TokenUsage.new(
230
+ token_usage: Riffer::Providers::TokenUsage.new(
228
231
  input_tokens: usage.input_tokens,
229
232
  output_tokens: usage.output_tokens
230
233
  )
@@ -242,11 +245,11 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
242
245
  def handle_output_item_done_web_search(event, yielder:)
243
246
  action = event.item.action
244
247
  case action
245
- when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
248
+ when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
246
249
  # OpenPage carries a url but no query or sources, so it doesn't fit
247
250
  # WebSearchDone — emit as a status notification instead.
248
251
  yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
249
- when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
252
+ when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
250
253
  sources = (action.sources || []).map { |s| {title: nil, url: s.url} }
251
254
  yielder << Riffer::StreamEvents::WebSearchDone.new(action.query, sources: sources)
252
255
  end
@@ -275,6 +278,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
275
278
  call_id: message.tool_call_id,
276
279
  output: message.content
277
280
  }
281
+ else
282
+ raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
278
283
  end
279
284
  end
280
285
  end
@@ -285,7 +290,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
285
290
  if message.tool_calls.empty?
286
291
  {role: "assistant", content: message.content}
287
292
  else
288
- items = []
293
+ items = [] #: Array[Hash[Symbol, untyped]]
289
294
  items << {type: "message", role: "assistant", content: message.content} if message.content && !message.content.empty?
290
295
  message.tool_calls.each do |tc|
291
296
  items << {
@@ -300,7 +305,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
300
305
  end
301
306
 
302
307
  #--
303
- #: (Riffer::FilePart) -> Hash[Symbol, untyped]
308
+ #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
304
309
  def convert_file_part_to_openai_format(file)
305
310
  if file.image?
306
311
  image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+
6
+ # OpenRouter provider for the OpenRouter unified gateway (https://openrouter.ai).
7
+ #
8
+ # Requires the +openai+ gem to be installed. OpenRouter exposes an
9
+ # OpenAI-compatible Chat Completions endpoint, so this provider reuses
10
+ # the OpenAI Ruby SDK with a +base_url+ override.
11
+ #
12
+ # The +api_key+ falls back to <tt>Riffer.config.openrouter.api_key</tt>
13
+ # and then to +OPENROUTER_API_KEY+.
14
+ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
15
+ BASE_URL = "https://openrouter.ai/api/v1" #: String
16
+
17
+ # Initializes the OpenRouter provider.
18
+ #
19
+ #--
20
+ #: (?api_key: String?, **untyped) -> void
21
+ def initialize(api_key: nil, **options)
22
+ depends_on "openai"
23
+
24
+ api_key ||= Riffer.config.openrouter.api_key || ENV["OPENROUTER_API_KEY"]
25
+
26
+ @client = ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL, **options)
27
+ end
28
+
29
+ private
30
+
31
+ #--
32
+ #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
33
+ def build_request_params(messages, model, options)
34
+ reasoning = options[:reasoning]
35
+ tools = options[:tools]
36
+ structured_output = options[:structured_output]
37
+
38
+ params = {
39
+ model: model,
40
+ messages: convert_messages_to_chat_completions_format(messages),
41
+ **options.except(:reasoning, :tools, :structured_output)
42
+ } #: Hash[Symbol, untyped]
43
+
44
+ if reasoning
45
+ params[:reasoning] = reasoning.is_a?(String) ? {effort: reasoning} : reasoning
46
+ end
47
+
48
+ if tools && !tools.empty?
49
+ params[:tools] = tools.map { |t| convert_tool_to_chat_completions_format(t) }
50
+ end
51
+
52
+ if structured_output
53
+ params[:response_format] = {
54
+ type: "json_schema",
55
+ json_schema: {
56
+ name: "response",
57
+ schema: structured_output.json_schema(strict: true),
58
+ strict: true
59
+ }
60
+ }
61
+ end
62
+
63
+ params.compact
64
+ end
65
+
66
+ #--
67
+ #: (Hash[Symbol, untyped]) -> untyped
68
+ def execute_generate(params)
69
+ @client.chat.completions.create(**params)
70
+ end
71
+
72
+ #--
73
+ #: (untyped) -> Riffer::Providers::TokenUsage?
74
+ def extract_token_usage(response)
75
+ typed_response = response #: OpenAI::Models::Chat::ChatCompletion
76
+ usage = typed_response.usage
77
+ return nil unless usage
78
+
79
+ Riffer::Providers::TokenUsage.new(
80
+ input_tokens: usage.prompt_tokens,
81
+ output_tokens: usage.completion_tokens
82
+ )
83
+ end
84
+
85
+ #--
86
+ #: (untyped) -> String
87
+ def extract_content(response)
88
+ typed_response = response #: OpenAI::Models::Chat::ChatCompletion
89
+ typed_response.choices.first&.message&.content || ""
90
+ end
91
+
92
+ #--
93
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
94
+ def extract_tool_calls(response)
95
+ typed_response = response #: OpenAI::Models::Chat::ChatCompletion
96
+ message = typed_response.choices.first&.message
97
+ return [] unless message
98
+
99
+ tool_calls = message.tool_calls
100
+ return [] if tool_calls.nil? || tool_calls.empty?
101
+
102
+ tool_calls.filter_map do |tc|
103
+ next unless tc.is_a?(::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall)
104
+
105
+ Riffer::Messages::Assistant::ToolCall.new(
106
+ call_id: tc.id,
107
+ name: decode_tool_name(tc.function.name, tools: @current_tools),
108
+ arguments: tc.function.arguments
109
+ )
110
+ end
111
+ end
112
+
113
+ #--
114
+ #: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
115
+ def execute_stream(params, yielder)
116
+ # OpenRouter omits usage from streams unless explicitly opted in.
117
+ stream_options = (params[:stream_options] || {}).merge(include_usage: true)
118
+ stream_params = params.merge(stream_options: stream_options)
119
+
120
+ state = {
121
+ text: +"",
122
+ reasoning: +"",
123
+ tool_calls: {}
124
+ } #: Hash[Symbol, untyped]
125
+
126
+ # Use stream_raw (not stream) — the latter yields a higher-level
127
+ # ChatChunkEvent helper that aggregates content/tool calls into typed
128
+ # events. We want raw ChatCompletionChunk objects with
129
+ # +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
130
+ # ourselves.
131
+ stream = @client.chat.completions.stream_raw(**stream_params)
132
+ begin
133
+ stream.each do |chunk|
134
+ handle_stream_chunk(chunk, state: state, yielder: yielder)
135
+ end
136
+ ensure
137
+ # The OpenAI SDK does not auto-close the SSE socket on iteration
138
+ # interrupt, so close explicitly. Idempotent and a no-op after EOF.
139
+ stream.close
140
+ end
141
+
142
+ # Chat Completions has no per-tool terminal event, so flush any leftover
143
+ # tool calls here in case finish_reason is missing or not "tool_calls".
144
+ emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?
145
+
146
+ yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
147
+ yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
148
+ end
149
+
150
+ #--
151
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
152
+ def handle_stream_chunk(chunk, state:, yielder:)
153
+ typed_chunk = chunk #: OpenAI::Models::Chat::ChatCompletionChunk
154
+ choice = typed_chunk.choices&.first
155
+ delta = choice&.delta
156
+
157
+ if delta
158
+ handle_text_delta(delta, state: state, yielder: yielder)
159
+ handle_reasoning_delta(delta, state: state, yielder: yielder)
160
+ handle_tool_call_deltas(delta, state: state, yielder: yielder)
161
+ end
162
+
163
+ if choice && finish_reason_is_tool_calls?(choice)
164
+ emit_tool_call_done_events(state: state, yielder: yielder)
165
+ end
166
+
167
+ return unless typed_chunk.usage
168
+
169
+ yielder << Riffer::StreamEvents::TokenUsageDone.new(
170
+ token_usage: Riffer::Providers::TokenUsage.new(
171
+ input_tokens: typed_chunk.usage.prompt_tokens,
172
+ output_tokens: typed_chunk.usage.completion_tokens
173
+ )
174
+ )
175
+ end
176
+
177
+ #--
178
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
179
+ def handle_text_delta(delta, state:, yielder:)
180
+ typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
181
+ content = typed_delta.content
182
+ return if content.nil? || content.empty?
183
+
184
+ state[:text] << content
185
+ yielder << Riffer::StreamEvents::TextDelta.new(content)
186
+ end
187
+
188
+ #--
189
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
190
+ def handle_reasoning_delta(delta, state:, yielder:)
191
+ # The openai gem's typed Delta model strips fields not in OpenAI's spec
192
+ # (so +delta.reasoning+ raises NoMethodError), but the underlying data
193
+ # hash retains them. Access via +#[]+ which reads from BaseModel#@data.
194
+ reasoning = delta[:reasoning] if delta.respond_to?(:[])
195
+ return if reasoning.nil? || reasoning.empty?
196
+
197
+ state[:reasoning] << reasoning
198
+ yielder << Riffer::StreamEvents::ReasoningDelta.new(reasoning)
199
+ end
200
+
201
+ #--
202
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
203
+ def handle_tool_call_deltas(delta, state:, yielder:)
204
+ typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
205
+ tool_calls = typed_delta.tool_calls
206
+ return if tool_calls.nil? || tool_calls.empty?
207
+
208
+ tool_calls.each do |tc|
209
+ entry = state[:tool_calls][tc.index] ||= {id: nil, name: nil, arguments: +""}
210
+ entry[:id] = tc.id if tc.id
211
+
212
+ fn = tc.function
213
+ next unless fn
214
+
215
+ entry[:name] = decode_tool_name(fn.name, tools: @current_tools) if fn.name
216
+
217
+ args_delta = fn.arguments
218
+ next if args_delta.nil? || args_delta.empty?
219
+
220
+ entry[:arguments] << args_delta
221
+ yielder << Riffer::StreamEvents::ToolCallDelta.new(
222
+ item_id: entry[:id] || "tool_#{tc.index}",
223
+ name: entry[:name],
224
+ arguments_delta: args_delta
225
+ )
226
+ end
227
+ end
228
+
229
+ #--
230
+ #: (state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
231
+ def emit_tool_call_done_events(state:, yielder:)
232
+ state[:tool_calls].each do |index, entry|
233
+ fallback = "tool_#{index}"
234
+ yielder << Riffer::StreamEvents::ToolCallDone.new(
235
+ item_id: entry[:id] || fallback,
236
+ call_id: entry[:id] || fallback,
237
+ name: entry[:name],
238
+ arguments: entry[:arguments]
239
+ )
240
+ end
241
+ state[:tool_calls] = {}
242
+ end
243
+
244
+ #--
245
+ #: (untyped) -> bool
246
+ def finish_reason_is_tool_calls?(choice)
247
+ typed_choice = choice #: OpenAI::Models::Chat::ChatCompletionChunk::Choice
248
+ typed_choice.finish_reason.to_s == "tool_calls"
249
+ end
250
+
251
+ #--
252
+ #: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
253
+ def convert_messages_to_chat_completions_format(messages)
254
+ messages.flat_map do |message|
255
+ case message
256
+ when Riffer::Messages::System
257
+ {role: "system", content: message.content}
258
+ when Riffer::Messages::User
259
+ if message.files.empty?
260
+ {role: "user", content: message.content}
261
+ else
262
+ content = [{type: "text", text: message.content}]
263
+ message.files.each { |file| content << convert_file_part_to_chat_completions_format(file) }
264
+ {role: "user", content: content}
265
+ end
266
+ when Riffer::Messages::Assistant
267
+ convert_assistant_to_chat_completions_format(message)
268
+ when Riffer::Messages::Tool
269
+ {role: "tool", tool_call_id: message.tool_call_id, content: message.content}
270
+ else
271
+ raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
272
+ end
273
+ end
274
+ end
275
+
276
+ #--
277
+ #: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
278
+ def convert_assistant_to_chat_completions_format(message)
279
+ msg = {role: "assistant"} #: Hash[Symbol, untyped]
280
+ msg[:content] = message.content if message.content && !message.content.empty?
281
+
282
+ unless message.tool_calls.empty?
283
+ msg[:tool_calls] = message.tool_calls.map do |tc|
284
+ {
285
+ id: tc.call_id,
286
+ type: "function",
287
+ function: {
288
+ name: encode_tool_name(tc.name),
289
+ arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
290
+ }
291
+ }
292
+ end
293
+ end
294
+
295
+ msg
296
+ end
297
+
298
+ #--
299
+ #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
300
+ def convert_file_part_to_chat_completions_format(file)
301
+ if file.image?
302
+ image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
303
+ {type: "image_url", image_url: {url: image_url}}
304
+ else
305
+ data_uri = "data:#{file.media_type};base64,#{file.data}"
306
+ block = {type: "file", file: {file_data: data_uri}} #: Hash[Symbol, untyped]
307
+ block[:file][:filename] = file.filename if file.filename
308
+ block
309
+ end
310
+ end
311
+
312
+ #--
313
+ #: (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]
314
+ def convert_tool_to_chat_completions_format(tool)
315
+ {
316
+ type: "function",
317
+ function: {
318
+ name: encode_tool_name(tool.name),
319
+ description: tool.description,
320
+ parameters: tool.parameters_schema(strict: true),
321
+ strict: true
322
+ }
323
+ }
324
+ end
325
+ end