riffer 0.27.2 → 0.29.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 (146) 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 +2 -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 +31 -0
  9. data/README.md +17 -18
  10. data/Steepfile +7 -1
  11. data/docs/03_AGENTS.md +34 -3
  12. data/docs/04_AGENT_LIFECYCLE.md +134 -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 +23 -19
  16. data/docs/08_MESSAGES.md +28 -31
  17. data/docs/09_STREAM_EVENTS.md +1 -1
  18. data/docs/10_CONFIGURATION.md +25 -15
  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 +125 -0
  26. data/lib/riffer/agent/response.rb +11 -2
  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 +268 -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 +246 -684
  33. data/lib/riffer/config.rb +56 -7
  34. data/lib/riffer/evals/evaluator.rb +13 -3
  35. data/lib/riffer/evals/judge.rb +2 -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 +1 -1
  43. data/lib/riffer/mcp/registration.rb +2 -3
  44. data/lib/riffer/mcp/registry.rb +3 -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} +5 -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 +19 -20
  56. data/lib/riffer/providers/anthropic.rb +27 -28
  57. data/lib/riffer/providers/base.rb +10 -9
  58. data/lib/riffer/providers/gemini.rb +15 -12
  59. data/lib/riffer/providers/mock.rb +41 -13
  60. data/lib/riffer/providers/open_ai.rb +24 -22
  61. data/lib/riffer/providers/open_router.rb +318 -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 +4 -3
  66. data/lib/riffer/runner/sequential.rb +1 -1
  67. data/lib/riffer/runner/threaded.rb +1 -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 +1 -1
  71. data/lib/riffer/skills/context.rb +3 -3
  72. data/lib/riffer/skills/filesystem_backend.rb +7 -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 +10 -3
  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/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  80. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  81. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  82. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +21 -15
  83. data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
  84. data/lib/riffer/version.rb +1 -1
  85. data/lib/riffer.rb +2 -1
  86. data/sig/generated/riffer/agent/config.rbs +119 -0
  87. data/sig/generated/riffer/agent/context.rbs +91 -0
  88. data/sig/generated/riffer/agent/response.rbs +10 -2
  89. data/sig/generated/riffer/agent/run.rbs +144 -0
  90. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  91. data/sig/generated/riffer/agent/session.rbs +145 -0
  92. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  93. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  94. data/sig/generated/riffer/agent.rbs +154 -225
  95. data/sig/generated/riffer/config.rbs +50 -5
  96. data/sig/generated/riffer/evals/judge.rbs +2 -2
  97. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  98. data/sig/generated/riffer/helpers.rbs +0 -1
  99. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  100. data/sig/generated/riffer/messages/base.rbs +18 -0
  101. data/sig/generated/riffer/messages/converter.rbs +4 -4
  102. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +5 -5
  103. data/sig/generated/riffer/messages/user.rbs +4 -4
  104. data/sig/generated/riffer/params/boolean.rbs +10 -0
  105. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  106. data/sig/generated/riffer/params.rbs +15 -15
  107. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  108. data/sig/generated/riffer/providers/anthropic.rbs +4 -4
  109. data/sig/generated/riffer/providers/base.rbs +10 -10
  110. data/sig/generated/riffer/providers/gemini.rbs +4 -4
  111. data/sig/generated/riffer/providers/mock.rbs +25 -5
  112. data/sig/generated/riffer/providers/open_ai.rbs +4 -4
  113. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  114. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  115. data/sig/generated/riffer/providers.rbs +1 -0
  116. data/sig/generated/riffer/runner/fibers.rbs +2 -2
  117. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  118. data/sig/generated/riffer/runner/threaded.rbs +2 -2
  119. data/sig/generated/riffer/runner.rbs +2 -2
  120. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  121. data/sig/generated/riffer/skills/config.rbs +1 -1
  122. data/sig/generated/riffer/skills/context.rbs +2 -2
  123. data/sig/generated/riffer/stream_events/interrupt.rbs +7 -2
  124. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  125. data/sig/generated/riffer/tool.rbs +5 -5
  126. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  127. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  128. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  129. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +19 -13
  130. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
  131. data/sig/stubs/agent_ivars.rbs +7 -0
  132. data/sig/stubs/async.rbs +24 -0
  133. data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
  134. data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
  135. data/sig/stubs/extend_self.rbs +11 -0
  136. data/sig/stubs/lib_ivars.rbs +101 -0
  137. data/sig/stubs/mcp_sdk.rbs +22 -0
  138. data/sig/stubs/provider_ivars.rbs +36 -0
  139. data/sig/stubs/provider_sdk_methods.rbs +50 -0
  140. data/sig/stubs/zeitwerk.rbs +12 -0
  141. metadata +54 -33
  142. data/lib/riffer/core.rb +0 -28
  143. data/lib/riffer/helpers/validations.rb +0 -18
  144. data/sig/generated/riffer/boolean.rbs +0 -10
  145. data/sig/generated/riffer/core.rbs +0 -19
  146. data/sig/generated/riffer/helpers/validations.rbs +0 -12
@@ -25,10 +25,21 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
25
25
 
26
26
  # Initializes the mock provider.
27
27
  #
28
+ # +responses:+ accepts an array of response hashes in the same shape
29
+ # +#stub_response+ takes — raw +tool_calls:+ hashes are normalised to
30
+ # +Riffer::Messages::Assistant::ToolCall+ instances. This is the canonical
31
+ # way to pre-configure canned LLM responses on an agent via
32
+ # +provider_options responses: [...]+.
33
+ #
34
+ # Riffer::Providers::Mock.new(responses: [
35
+ # {content: "", tool_calls: [{name: "tool_a", arguments: "{}"}]},
36
+ # {content: "Final answer"}
37
+ # ])
38
+ #
28
39
  #--
29
40
  #: (**untyped) -> void
30
41
  def initialize(**options)
31
- @responses = options[:responses] || []
42
+ @responses = (options[:responses] || []).map { |r| normalize_response(r) }
32
43
  @current_index = 0
33
44
  @calls = []
34
45
  @stubbed_responses = []
@@ -40,19 +51,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
40
51
  #
41
52
  # provider.stub_response("Hello")
42
53
  # 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))
54
+ # provider.stub_response("Final response", token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
44
55
  #
45
56
  #--
46
- #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::TokenUsage?) -> void
57
+ #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?) -> void
47
58
  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}
59
+ @stubbed_responses << normalize_response(content: content, tool_calls: tool_calls, token_usage: token_usage)
56
60
  end
57
61
 
58
62
  # Clears all stubbed responses.
@@ -65,6 +69,30 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
65
69
 
66
70
  private
67
71
 
72
+ # Normalises a response hash into Mock's internal format. Accepts the
73
+ # +#stub_response+ kwargs shape (+content:+, +tool_calls:+, +token_usage:+)
74
+ # or a pre-built hash with already-converted ToolCall instances. Raw
75
+ # +tool_calls:+ hashes are wrapped in +Riffer::Messages::Assistant::ToolCall+.
76
+ #
77
+ #--
78
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
79
+ def normalize_response(response)
80
+ formatted_tool_calls = (response[:tool_calls] || []).map.with_index do |tc, idx|
81
+ next tc if tc.is_a?(Riffer::Messages::Assistant::ToolCall)
82
+ Riffer::Messages::Assistant::ToolCall.new(
83
+ call_id: tc[:call_id] || tc[:id] || "mock_call_#{idx}",
84
+ name: tc[:name],
85
+ arguments: tc[:arguments].is_a?(String) ? tc[:arguments] : tc[:arguments].to_json
86
+ )
87
+ end
88
+ {
89
+ role: response[:role] || "assistant",
90
+ content: response[:content] || "",
91
+ tool_calls: formatted_tool_calls,
92
+ token_usage: response[:token_usage]
93
+ }
94
+ end
95
+
68
96
  #--
69
97
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
70
98
  def build_request_params(messages, model, options)
@@ -82,7 +110,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
82
110
  end
83
111
 
84
112
  #--
85
- #: (untyped) -> Riffer::TokenUsage?
113
+ #: (untyped) -> Riffer::Providers::TokenUsage?
86
114
  def extract_token_usage(response)
87
115
  response[:token_usage]
88
116
  end
@@ -146,7 +174,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
146
174
  def next_response
147
175
  if @stubbed_responses.any?
148
176
  @stubbed_responses.shift
149
- elsif @responses[@current_index]
177
+ elsif @current_index < @responses.size
150
178
  response = @responses[@current_index]
151
179
  @current_index += 1
152
180
  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
@@ -72,12 +72,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
72
72
  end
73
73
 
74
74
  #--
75
- #: (OpenAI::Models::Responses::Response) -> Riffer::TokenUsage?
75
+ #: (OpenAI::Models::Responses::Response) -> Riffer::Providers::TokenUsage?
76
76
  def extract_token_usage(response)
77
77
  usage = response.usage
78
78
  return nil unless usage
79
79
 
80
- Riffer::TokenUsage.new(
80
+ Riffer::Providers::TokenUsage.new(
81
81
  input_tokens: usage.input_tokens,
82
82
  output_tokens: usage.output_tokens
83
83
  )
@@ -89,10 +89,10 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
89
89
  text_content = ""
90
90
 
91
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
92
+ next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
93
+
94
+ text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
95
+ text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
96
96
  end
97
97
 
98
98
  text_content
@@ -101,16 +101,16 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
101
101
  #--
102
102
  #: (OpenAI::Models::Responses::Response) -> Array[Riffer::Messages::Assistant::ToolCall]
103
103
  def extract_tool_calls(response)
104
- tool_calls = []
104
+ tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
105
105
 
106
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
107
+ next unless item.is_a?(::OpenAI::Models::Responses::ResponseFunctionToolCall)
108
+
109
+ tool_calls << Riffer::Messages::Assistant::ToolCall.new(
110
+ call_id: item.call_id,
111
+ name: decode_tool_name(item.name, tools: @current_tools),
112
+ arguments: item.arguments
113
+ )
114
114
  end
115
115
 
116
116
  tool_calls
@@ -121,7 +121,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
121
121
  def execute_stream(params, yielder)
122
122
  current_state = {
123
123
  tool_info: {}
124
- }
124
+ } #: Hash[Symbol, untyped]
125
125
 
126
126
  stream = @client.responses.stream(params)
127
127
  begin
@@ -224,7 +224,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
224
224
  return unless usage
225
225
 
226
226
  yielder << Riffer::StreamEvents::TokenUsageDone.new(
227
- token_usage: Riffer::TokenUsage.new(
227
+ token_usage: Riffer::Providers::TokenUsage.new(
228
228
  input_tokens: usage.input_tokens,
229
229
  output_tokens: usage.output_tokens
230
230
  )
@@ -242,11 +242,11 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
242
242
  def handle_output_item_done_web_search(event, yielder:)
243
243
  action = event.item.action
244
244
  case action
245
- when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
245
+ when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
246
246
  # OpenPage carries a url but no query or sources, so it doesn't fit
247
247
  # WebSearchDone — emit as a status notification instead.
248
248
  yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
249
- when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
249
+ when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
250
250
  sources = (action.sources || []).map { |s| {title: nil, url: s.url} }
251
251
  yielder << Riffer::StreamEvents::WebSearchDone.new(action.query, sources: sources)
252
252
  end
@@ -275,6 +275,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
275
275
  call_id: message.tool_call_id,
276
276
  output: message.content
277
277
  }
278
+ else
279
+ raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
278
280
  end
279
281
  end
280
282
  end
@@ -285,7 +287,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
285
287
  if message.tool_calls.empty?
286
288
  {role: "assistant", content: message.content}
287
289
  else
288
- items = []
290
+ items = [] #: Array[Hash[Symbol, untyped]]
289
291
  items << {type: "message", role: "assistant", content: message.content} if message.content && !message.content.empty?
290
292
  message.tool_calls.each do |tc|
291
293
  items << {
@@ -300,7 +302,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
300
302
  end
301
303
 
302
304
  #--
303
- #: (Riffer::FilePart) -> Hash[Symbol, untyped]
305
+ #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
304
306
  def convert_file_part_to_openai_format(file)
305
307
  if file.image?
306
308
  image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
@@ -0,0 +1,318 @@
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]) -> OpenAI::Models::Chat::ChatCompletion
68
+ def execute_generate(params)
69
+ @client.chat.completions.create(**params)
70
+ end
71
+
72
+ #--
73
+ #: (OpenAI::Models::Chat::ChatCompletion) -> Riffer::Providers::TokenUsage?
74
+ def extract_token_usage(response)
75
+ usage = response.usage
76
+ return nil unless usage
77
+
78
+ Riffer::Providers::TokenUsage.new(
79
+ input_tokens: usage.prompt_tokens,
80
+ output_tokens: usage.completion_tokens
81
+ )
82
+ end
83
+
84
+ #--
85
+ #: (OpenAI::Models::Chat::ChatCompletion) -> String
86
+ def extract_content(response)
87
+ response.choices.first&.message&.content || ""
88
+ end
89
+
90
+ #--
91
+ #: (OpenAI::Models::Chat::ChatCompletion) -> Array[Riffer::Messages::Assistant::ToolCall]
92
+ def extract_tool_calls(response)
93
+ message = response.choices.first&.message
94
+ return [] unless message
95
+
96
+ tool_calls = message.tool_calls
97
+ return [] if tool_calls.nil? || tool_calls.empty?
98
+
99
+ tool_calls.filter_map do |tc|
100
+ next unless tc.is_a?(::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall)
101
+
102
+ Riffer::Messages::Assistant::ToolCall.new(
103
+ call_id: tc.id,
104
+ name: decode_tool_name(tc.function.name, tools: @current_tools),
105
+ arguments: tc.function.arguments
106
+ )
107
+ end
108
+ end
109
+
110
+ #--
111
+ #: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
112
+ def execute_stream(params, yielder)
113
+ # OpenRouter omits usage from streams unless explicitly opted in.
114
+ stream_options = (params[:stream_options] || {}).merge(include_usage: true)
115
+ stream_params = params.merge(stream_options: stream_options)
116
+
117
+ state = {
118
+ text: +"",
119
+ reasoning: +"",
120
+ tool_calls: {}
121
+ } #: Hash[Symbol, untyped]
122
+
123
+ # Use stream_raw (not stream) — the latter yields a higher-level
124
+ # ChatChunkEvent helper that aggregates content/tool calls into typed
125
+ # events. We want raw ChatCompletionChunk objects with
126
+ # +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
127
+ # ourselves.
128
+ stream = @client.chat.completions.stream_raw(**stream_params)
129
+ begin
130
+ stream.each do |chunk|
131
+ handle_stream_chunk(chunk, state: state, yielder: yielder)
132
+ end
133
+ ensure
134
+ # The OpenAI SDK does not auto-close the SSE socket on iteration
135
+ # interrupt, so close explicitly. Idempotent and a no-op after EOF.
136
+ stream.close
137
+ end
138
+
139
+ # Chat Completions has no per-tool terminal event, so flush any leftover
140
+ # tool calls here in case finish_reason is missing or not "tool_calls".
141
+ emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?
142
+
143
+ yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
144
+ yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
145
+ end
146
+
147
+ #--
148
+ #: (OpenAI::Models::Chat::ChatCompletionChunk, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
149
+ def handle_stream_chunk(chunk, state:, yielder:)
150
+ choice = chunk.choices&.first
151
+ delta = choice&.delta
152
+
153
+ if delta
154
+ handle_text_delta(delta, state: state, yielder: yielder)
155
+ handle_reasoning_delta(delta, state: state, yielder: yielder)
156
+ handle_tool_call_deltas(delta, state: state, yielder: yielder)
157
+ end
158
+
159
+ if choice && finish_reason_is_tool_calls?(choice)
160
+ emit_tool_call_done_events(state: state, yielder: yielder)
161
+ end
162
+
163
+ return unless chunk.usage
164
+
165
+ yielder << Riffer::StreamEvents::TokenUsageDone.new(
166
+ token_usage: Riffer::Providers::TokenUsage.new(
167
+ input_tokens: chunk.usage.prompt_tokens,
168
+ output_tokens: chunk.usage.completion_tokens
169
+ )
170
+ )
171
+ end
172
+
173
+ #--
174
+ #: (OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
175
+ def handle_text_delta(delta, state:, yielder:)
176
+ content = delta.content
177
+ return if content.nil? || content.empty?
178
+
179
+ state[:text] << content
180
+ yielder << Riffer::StreamEvents::TextDelta.new(content)
181
+ end
182
+
183
+ #--
184
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
185
+ def handle_reasoning_delta(delta, state:, yielder:)
186
+ # The openai gem's typed Delta model strips fields not in OpenAI's spec
187
+ # (so +delta.reasoning+ raises NoMethodError), but the underlying data
188
+ # hash retains them. Access via +#[]+ which reads from BaseModel#@data.
189
+ reasoning = delta[:reasoning] if delta.respond_to?(:[])
190
+ return if reasoning.nil? || reasoning.empty?
191
+
192
+ state[:reasoning] << reasoning
193
+ yielder << Riffer::StreamEvents::ReasoningDelta.new(reasoning)
194
+ end
195
+
196
+ #--
197
+ #: (OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
198
+ def handle_tool_call_deltas(delta, state:, yielder:)
199
+ tool_calls = delta.tool_calls
200
+ return if tool_calls.nil? || tool_calls.empty?
201
+
202
+ tool_calls.each do |tc|
203
+ entry = state[:tool_calls][tc.index] ||= {id: nil, name: nil, arguments: +""}
204
+ entry[:id] = tc.id if tc.id
205
+
206
+ fn = tc.function
207
+ next unless fn
208
+
209
+ entry[:name] = decode_tool_name(fn.name, tools: @current_tools) if fn.name
210
+
211
+ args_delta = fn.arguments
212
+ next if args_delta.nil? || args_delta.empty?
213
+
214
+ entry[:arguments] << args_delta
215
+ yielder << Riffer::StreamEvents::ToolCallDelta.new(
216
+ item_id: entry[:id] || "tool_#{tc.index}",
217
+ name: entry[:name],
218
+ arguments_delta: args_delta
219
+ )
220
+ end
221
+ end
222
+
223
+ #--
224
+ #: (state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
225
+ def emit_tool_call_done_events(state:, yielder:)
226
+ state[:tool_calls].each do |index, entry|
227
+ fallback = "tool_#{index}"
228
+ yielder << Riffer::StreamEvents::ToolCallDone.new(
229
+ item_id: entry[:id] || fallback,
230
+ call_id: entry[:id] || fallback,
231
+ name: entry[:name],
232
+ arguments: entry[:arguments]
233
+ )
234
+ end
235
+ state[:tool_calls] = {}
236
+ end
237
+
238
+ #--
239
+ #: (OpenAI::Models::Chat::ChatCompletionChunk::Choice) -> bool
240
+ def finish_reason_is_tool_calls?(choice)
241
+ choice.finish_reason.to_s == "tool_calls"
242
+ end
243
+
244
+ #--
245
+ #: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
246
+ def convert_messages_to_chat_completions_format(messages)
247
+ messages.flat_map do |message|
248
+ case message
249
+ when Riffer::Messages::System
250
+ {role: "system", content: message.content}
251
+ when Riffer::Messages::User
252
+ if message.files.empty?
253
+ {role: "user", content: message.content}
254
+ else
255
+ content = [{type: "text", text: message.content}]
256
+ message.files.each { |file| content << convert_file_part_to_chat_completions_format(file) }
257
+ {role: "user", content: content}
258
+ end
259
+ when Riffer::Messages::Assistant
260
+ convert_assistant_to_chat_completions_format(message)
261
+ when Riffer::Messages::Tool
262
+ {role: "tool", tool_call_id: message.tool_call_id, content: message.content}
263
+ else
264
+ raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
265
+ end
266
+ end
267
+ end
268
+
269
+ #--
270
+ #: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
271
+ def convert_assistant_to_chat_completions_format(message)
272
+ msg = {role: "assistant"} #: Hash[Symbol, untyped]
273
+ msg[:content] = message.content if message.content && !message.content.empty?
274
+
275
+ unless message.tool_calls.empty?
276
+ msg[:tool_calls] = message.tool_calls.map do |tc|
277
+ {
278
+ id: tc.call_id,
279
+ type: "function",
280
+ function: {
281
+ name: encode_tool_name(tc.name),
282
+ arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
283
+ }
284
+ }
285
+ end
286
+ end
287
+
288
+ msg
289
+ end
290
+
291
+ #--
292
+ #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
293
+ def convert_file_part_to_chat_completions_format(file)
294
+ if file.image?
295
+ image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
296
+ {type: "image_url", image_url: {url: image_url}}
297
+ else
298
+ data_uri = "data:#{file.media_type};base64,#{file.data}"
299
+ block = {type: "file", file: {file_data: data_uri}} #: Hash[Symbol, untyped]
300
+ block[:file][:filename] = file.filename if file.filename
301
+ block
302
+ end
303
+ end
304
+
305
+ #--
306
+ #: (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]
307
+ def convert_tool_to_chat_completions_format(tool)
308
+ {
309
+ type: "function",
310
+ function: {
311
+ name: encode_tool_name(tool.name),
312
+ description: tool.description,
313
+ parameters: tool.parameters_schema(strict: true),
314
+ strict: true
315
+ }
316
+ }
317
+ end
318
+ end
@@ -10,6 +10,7 @@ class Riffer::Providers::Repository
10
10
  azure_openai: -> { Riffer::Providers::AzureOpenAI },
11
11
  gemini: -> { Riffer::Providers::Gemini },
12
12
  openai: -> { Riffer::Providers::OpenAI },
13
+ openrouter: -> { Riffer::Providers::OpenRouter },
13
14
  mock: -> { Riffer::Providers::Mock }
14
15
  }.freeze #: Hash[Symbol, ^() -> singleton(Riffer::Providers::Base)]
15
16
 
@@ -5,12 +5,12 @@
5
5
  #
6
6
  # Tracks input tokens, output tokens, and optional cache statistics.
7
7
  #
8
- # token_usage = Riffer::TokenUsage.new(input_tokens: 100, output_tokens: 50)
8
+ # token_usage = Riffer::Providers::TokenUsage.new(input_tokens: 100, output_tokens: 50)
9
9
  # token_usage.total_tokens # => 150
10
10
  #
11
11
  # combined = token_usage1 + token_usage2 # Combine multiple token usage objects
12
12
  #
13
- class Riffer::TokenUsage
13
+ class Riffer::Providers::TokenUsage
14
14
  # Number of tokens in the input/prompt.
15
15
  attr_reader :input_tokens #: Integer
16
16
 
@@ -43,9 +43,9 @@ class Riffer::TokenUsage
43
43
  # Combines two TokenUsage objects for cumulative tracking.
44
44
  #
45
45
  #--
46
- #: (Riffer::TokenUsage) -> Riffer::TokenUsage
46
+ #: (Riffer::Providers::TokenUsage) -> Riffer::Providers::TokenUsage
47
47
  def +(other)
48
- Riffer::TokenUsage.new(
48
+ Riffer::Providers::TokenUsage.new(
49
49
  input_tokens: input_tokens + other.input_tokens,
50
50
  output_tokens: output_tokens + other.output_tokens,
51
51
  cache_creation_tokens: add_nullable(cache_creation_tokens, other.cache_creation_tokens),
@@ -7,6 +7,7 @@
7
7
  # - Riffer::Providers::OpenAI - OpenAI GPT models
8
8
  # - Riffer::Providers::AzureOpenAI - Azure OpenAI GPT models
9
9
  # - Riffer::Providers::AmazonBedrock - AWS Bedrock models
10
+ # - Riffer::Providers::OpenRouter - OpenRouter unified gateway
10
11
  # - Riffer::Providers::Mock - Mock provider for testing
11
12
  module Riffer::Providers
12
13
  end
@@ -28,7 +28,7 @@ class Riffer::Runner::Fibers < Riffer::Runner
28
28
  end
29
29
 
30
30
  #--
31
- #: (Array[untyped], context: Hash[Symbol, untyped]?) { (untyped) -> untyped } -> Array[untyped]
31
+ #: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
32
32
  def map(items, context:, &block)
33
33
  return [] if items.empty?
34
34
 
@@ -37,8 +37,9 @@ class Riffer::Runner::Fibers < Riffer::Runner
37
37
 
38
38
  Async do
39
39
  barrier = Async::Barrier.new
40
- parent = if @max_concurrency
41
- Async::Semaphore.new(@max_concurrency, parent: barrier)
40
+ max = @max_concurrency
41
+ parent = if max
42
+ Async::Semaphore.new(max, parent: barrier)
42
43
  else
43
44
  barrier
44
45
  end
@@ -7,7 +7,7 @@
7
7
  #
8
8
  class Riffer::Runner::Sequential < Riffer::Runner
9
9
  #--
10
- #: (Array[untyped], context: Hash[Symbol, untyped]?) { (untyped) -> untyped } -> Array[untyped]
10
+ #: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
11
11
  def map(items, context:, &block)
12
12
  items.map(&block)
13
13
  end
@@ -25,7 +25,7 @@ class Riffer::Runner::Threaded < Riffer::Runner
25
25
  end
26
26
 
27
27
  #--
28
- #: (Array[untyped], context: Hash[Symbol, untyped]?) { (untyped) -> untyped } -> Array[untyped]
28
+ #: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
29
29
  def map(items, context:, &block)
30
30
  return [] if items.empty?
31
31
 
data/lib/riffer/runner.rb CHANGED
@@ -19,7 +19,7 @@ class Riffer::Runner
19
19
  # Raises NotImplementedError if not implemented by subclass.
20
20
  #
21
21
  #--
22
- #: (Array[untyped], context: Hash[Symbol, untyped]?) { (untyped) -> untyped } -> Array[untyped]
22
+ #: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
23
23
  def map(items, context:, &block)
24
24
  raise NotImplementedError, "#{self.class} must implement #map"
25
25
  end
@@ -19,13 +19,14 @@ class Riffer::Skills::ActivateTool < Riffer::Tool
19
19
 
20
20
  # Activates a skill by name and returns its body.
21
21
  #
22
- # [context] tool context containing +:skills+ (a Riffer::Skills::Context).
22
+ # [context] the agent's +Riffer::Agent::Context+, exposing +#skills+
23
+ # (a +Riffer::Skills::Context+).
23
24
  # [name] the skill name to activate.
24
25
  #
25
26
  #--
26
- #: (context: Hash[Symbol, untyped]?, name: String) -> Riffer::Tools::Response
27
+ #: (context: Riffer::Agent::Context?, name: String) -> Riffer::Tools::Response
27
28
  def call(context:, name:)
28
- skills_context = context&.dig(:skills)
29
+ skills_context = context&.skills
29
30
  return error("Skills not configured") unless skills_context
30
31
 
31
32
  text(skills_context.activate(name))
@@ -66,7 +66,7 @@ class Riffer::Skills::Config
66
66
  # Returns the configured override when set, or +nil+ when unset. The
67
67
  # global fallback to <tt>Riffer.config.skills.default_activate_tool</tt>
68
68
  # is applied by the agent at resolution time (see
69
- # Riffer::Agent.resolved_tool_classes), not by this getter.
69
+ # Riffer::Agent#resolve_tools), not by this getter.
70
70
  #
71
71
  # The override must be a subclass of Riffer::Tool.
72
72
  #
@@ -6,8 +6,8 @@
6
6
  # Coordinates skill discovery, activation, and prompt rendering.
7
7
  # Tracks activations with caching to avoid redundant backend reads.
8
8
  #
9
- # Built by the agent at the start of generate/stream and passed to
10
- # tools via +context[:skills]+.
9
+ # Built by the agent during +Agent.new+ and exposed to tools via
10
+ # <tt>context.skills</tt> on the agent's +Riffer::Agent::Context+.
11
11
  #
12
12
  # See Riffer::Skills::Backend, Riffer::Skills::Frontmatter.
13
13
  class Riffer::Skills::Context
@@ -66,7 +66,7 @@ class Riffer::Skills::Context
66
66
  #: () -> String
67
67
  def system_prompt
68
68
  available = available_skills
69
- parts = []
69
+ parts = [] #: Array[String]
70
70
  parts << @adapter.render_catalog(available) unless available.empty?
71
71
  @activated.each_value { |body| parts << body }
72
72
  parts.join("\n\n")