riffer 0.32.1 → 0.34.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +39 -0
  5. data/README.md +13 -11
  6. data/docs/01_OVERVIEW.md +2 -0
  7. data/docs/04_AGENT_LIFECYCLE.md +15 -13
  8. data/docs/08_MESSAGES.md +39 -5
  9. data/docs/09_STREAM_EVENTS.md +14 -0
  10. data/docs/10_CONFIGURATION.md +73 -4
  11. data/docs/13_SKILLS.md +62 -4
  12. data/docs/14_MCP.md +2 -1
  13. data/docs/16_TRACING.md +274 -0
  14. data/docs/17_METRICS.md +153 -0
  15. data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
  16. data/lib/riffer/agent/response.rb +11 -2
  17. data/lib/riffer/agent/run.rb +136 -35
  18. data/lib/riffer/agent.rb +1 -1
  19. data/lib/riffer/config.rb +230 -15
  20. data/lib/riffer/guardrail.rb +8 -0
  21. data/lib/riffer/guardrails/runner.rb +58 -0
  22. data/lib/riffer/helpers/boolean.rb +22 -0
  23. data/lib/riffer/mcp/authenticated_tool.rb +14 -20
  24. data/lib/riffer/mcp/registration.rb +4 -4
  25. data/lib/riffer/mcp/tool.rb +23 -0
  26. data/lib/riffer/mcp/tool_factory.rb +14 -22
  27. data/lib/riffer/messages/assistant.rb +15 -3
  28. data/lib/riffer/messages/base.rb +2 -1
  29. data/lib/riffer/metrics/instruments.rb +31 -0
  30. data/lib/riffer/metrics/no_op.rb +14 -0
  31. data/lib/riffer/metrics/otel.rb +80 -0
  32. data/lib/riffer/metrics.rb +94 -0
  33. data/lib/riffer/providers/amazon_bedrock.rb +57 -21
  34. data/lib/riffer/providers/anthropic.rb +59 -24
  35. data/lib/riffer/providers/azure_open_ai.rb +7 -0
  36. data/lib/riffer/providers/base.rb +247 -15
  37. data/lib/riffer/providers/finish_reason.rb +27 -0
  38. data/lib/riffer/providers/gemini.rb +59 -11
  39. data/lib/riffer/providers/mock.rb +30 -9
  40. data/lib/riffer/providers/open_ai.rb +78 -24
  41. data/lib/riffer/providers/open_router.rb +56 -16
  42. data/lib/riffer/providers/repository.rb +9 -0
  43. data/lib/riffer/providers/token_usage.rb +27 -11
  44. data/lib/riffer/skills/activate_tool.rb +11 -2
  45. data/lib/riffer/skills/adapter.rb +15 -0
  46. data/lib/riffer/skills/context.rb +63 -11
  47. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  48. data/lib/riffer/skills/xml_adapter.rb +1 -1
  49. data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
  50. data/lib/riffer/tools/runtime.rb +99 -3
  51. data/lib/riffer/tracing/capture.rb +92 -0
  52. data/lib/riffer/tracing/no_op.rb +61 -0
  53. data/lib/riffer/tracing/otel.rb +132 -0
  54. data/lib/riffer/tracing/stream_recorder.rb +51 -0
  55. data/lib/riffer/tracing.rb +78 -0
  56. data/lib/riffer/version.rb +1 -1
  57. data/sig/_private/opentelemetry.rbs +22 -0
  58. data/sig/generated/riffer/agent/response.rbs +9 -2
  59. data/sig/generated/riffer/agent/run.rbs +28 -8
  60. data/sig/generated/riffer/config.rbs +162 -16
  61. data/sig/generated/riffer/guardrail.rbs +6 -0
  62. data/sig/generated/riffer/guardrails/runner.rbs +22 -0
  63. data/sig/generated/riffer/helpers/boolean.rbs +11 -0
  64. data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
  65. data/sig/generated/riffer/mcp/registration.rbs +4 -4
  66. data/sig/generated/riffer/mcp/tool.rbs +19 -0
  67. data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
  68. data/sig/generated/riffer/messages/assistant.rbs +10 -4
  69. data/sig/generated/riffer/metrics/instruments.rbs +15 -0
  70. data/sig/generated/riffer/metrics/no_op.rbs +10 -0
  71. data/sig/generated/riffer/metrics/otel.rbs +48 -0
  72. data/sig/generated/riffer/metrics.rbs +72 -0
  73. data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
  74. data/sig/generated/riffer/providers/anthropic.rbs +41 -20
  75. data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
  76. data/sig/generated/riffer/providers/base.rbs +78 -2
  77. data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
  78. data/sig/generated/riffer/providers/gemini.rbs +25 -2
  79. data/sig/generated/riffer/providers/mock.rbs +16 -5
  80. data/sig/generated/riffer/providers/open_ai.rbs +44 -22
  81. data/sig/generated/riffer/providers/open_router.rbs +31 -12
  82. data/sig/generated/riffer/providers/repository.rbs +7 -0
  83. data/sig/generated/riffer/providers/token_usage.rbs +20 -10
  84. data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
  85. data/sig/generated/riffer/skills/adapter.rbs +10 -0
  86. data/sig/generated/riffer/skills/context.rbs +42 -4
  87. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
  88. data/sig/generated/riffer/tools/runtime.rbs +35 -0
  89. data/sig/generated/riffer/tracing/capture.rbs +46 -0
  90. data/sig/generated/riffer/tracing/no_op.rbs +46 -0
  91. data/sig/generated/riffer/tracing/otel.rbs +84 -0
  92. data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
  93. data/sig/generated/riffer/tracing.rbs +52 -0
  94. data/sig/manual/riffer/helpers/boolean.rbs +5 -0
  95. data/sig/manual/riffer/metrics/no_op.rbs +5 -0
  96. data/sig/manual/riffer/metrics.rbs +5 -0
  97. data/sig/manual/riffer/providers.rbs +9 -0
  98. data/sig/manual/riffer/tracing/capture.rbs +5 -0
  99. data/sig/manual/riffer/tracing/no_op.rbs +5 -0
  100. data/sig/manual/riffer/tracing.rbs +5 -0
  101. metadata +42 -6
@@ -16,6 +16,13 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
16
16
  Riffer::Skills::MarkdownAdapter
17
17
  end
18
18
 
19
+ # The GenAI semconv well-known provider name.
20
+ #--
21
+ #: () -> String
22
+ def self.semconv_provider_name
23
+ "mock"
24
+ end
25
+
19
26
  # Array of recorded method calls for assertions.
20
27
  attr_reader :calls #: Array[Hash[Symbol, untyped]]
21
28
 
@@ -36,16 +43,18 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
36
43
  @stubbed_responses = []
37
44
  end
38
45
 
39
- # Stubs the next response; call repeatedly to queue several.
46
+ # Stubs the next response; call repeatedly to queue several. +finish_reason+
47
+ # defaults to +:tool_calls+ when tool calls are present, else +:stop+.
40
48
  #
41
49
  # provider.stub_response("Hello")
42
50
  # provider.stub_response("", tool_calls: [{name: "my_tool", arguments: '{"key":"value"}'}])
43
51
  # provider.stub_response("Final response", token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
52
+ # provider.stub_response("Truncated...", finish_reason: :length)
44
53
  #
45
54
  #--
46
- #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?) -> void
47
- def stub_response(content, tool_calls: [], token_usage: nil)
48
- @stubbed_responses << normalize_response(content: content, tool_calls: tool_calls, token_usage: token_usage)
55
+ #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?, ?finish_reason: Symbol?) -> void
56
+ def stub_response(content, tool_calls: [], token_usage: nil, finish_reason: nil)
57
+ @stubbed_responses << normalize_response(content: content, tool_calls: tool_calls, token_usage: token_usage, finish_reason: finish_reason)
49
58
  end
50
59
 
51
60
  # Clears all stubbed responses.
@@ -73,7 +82,8 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
73
82
  role: response[:role] || "assistant",
74
83
  content: response[:content] || "",
75
84
  tool_calls: formatted_tool_calls,
76
- token_usage: response[:token_usage]
85
+ token_usage: response[:token_usage],
86
+ finish_reason: response[:finish_reason] || (formatted_tool_calls.empty? ? :stop : :tool_calls)
77
87
  }
78
88
  end
79
89
 
@@ -96,7 +106,17 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
96
106
  #--
97
107
  #: (untyped) -> Riffer::Providers::TokenUsage?
98
108
  def extract_token_usage(response)
99
- response[:token_usage]
109
+ usage = response[:token_usage]
110
+ usage && apply_pricing(usage)
111
+ end
112
+
113
+ #--
114
+ #: (untyped) -> Riffer::Providers::FinishReason?
115
+ def extract_finish_reason(response)
116
+ return nil unless response.is_a?(Hash)
117
+
118
+ reason = response[:finish_reason]
119
+ reason ? Riffer::Providers::FinishReason.new(reason: reason) : nil
100
120
  end
101
121
 
102
122
  #--
@@ -112,7 +132,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
112
132
  end
113
133
 
114
134
  #--
115
- #: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
135
+ #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
116
136
  def execute_stream(params, yielder)
117
137
  response = params[:response]
118
138
  full_content = response[:content] || ""
@@ -150,7 +170,8 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
150
170
  end
151
171
 
152
172
  yielder << Riffer::StreamEvents::TextDone.new(full_content)
153
- yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: token_usage) if token_usage
173
+ yield_finish_reason(yielder, extract_finish_reason(response))
174
+ yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: apply_pricing(token_usage)) if token_usage
154
175
  end
155
176
 
156
177
  #--
@@ -163,7 +184,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
163
184
  @current_index += 1
164
185
  response
165
186
  else
166
- {role: "assistant", content: "Mock response"}
187
+ normalize_response(content: "Mock response")
167
188
  end
168
189
  end
169
190
  end
@@ -5,6 +5,13 @@
5
5
  class Riffer::Providers::OpenAI < Riffer::Providers::Base
6
6
  WEB_SEARCH_TOOL_TYPE = "web_search_preview" #: String
7
7
 
8
+ # The GenAI semconv well-known provider name.
9
+ #--
10
+ #: () -> String
11
+ def self.semconv_provider_name
12
+ "openai"
13
+ end
14
+
8
15
  #--
9
16
  #: (**untyped) -> void
10
17
  def initialize(**options)
@@ -74,11 +81,59 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
74
81
  usage = typed_response.usage
75
82
  return nil unless usage
76
83
 
77
- Riffer::Providers::TokenUsage.new(
84
+ build_token_usage(usage)
85
+ end
86
+
87
+ #--
88
+ #: (untyped) -> Riffer::Providers::TokenUsage
89
+ def build_token_usage(usage)
90
+ apply_pricing(Riffer::Providers::TokenUsage.new(
78
91
  input_tokens: usage.input_tokens,
79
92
  output_tokens: usage.output_tokens,
80
93
  cache_read_tokens: usage.input_tokens_details&.cached_tokens
81
- )
94
+ ))
95
+ end
96
+
97
+ #--
98
+ #: (untyped) -> Riffer::Providers::FinishReason?
99
+ def extract_finish_reason(response)
100
+ build_finish_reason(response)
101
+ end
102
+
103
+ # The Responses API reports no finish_reason field, so one is derived.
104
+ #--
105
+ #: (untyped) -> Riffer::Providers::FinishReason?
106
+ def build_finish_reason(response)
107
+ typed_response = response #: OpenAI::Models::Responses::Response
108
+ status = typed_response.status
109
+ return nil unless status
110
+
111
+ case status.to_sym
112
+ when :completed
113
+ reason = extract_tool_calls(typed_response).empty? ? :stop : :tool_calls
114
+ Riffer::Providers::FinishReason.new(reason: reason, raw: "completed")
115
+ when :incomplete
116
+ incomplete_finish_reason(typed_response)
117
+ when :failed
118
+ Riffer::Providers::FinishReason.new(reason: :error, raw: "failed")
119
+ else
120
+ Riffer::Providers::FinishReason.new(reason: :other, raw: status.to_s)
121
+ end
122
+ end
123
+
124
+ #--
125
+ #: (untyped) -> Riffer::Providers::FinishReason
126
+ def incomplete_finish_reason(response)
127
+ typed_response = response #: OpenAI::Models::Responses::Response
128
+ raw = typed_response.incomplete_details&.reason&.to_s
129
+
130
+ reason = case raw
131
+ when "max_output_tokens" then :length
132
+ when "content_filter" then :content_filter
133
+ else :other
134
+ end
135
+
136
+ Riffer::Providers::FinishReason.new(reason: reason, raw: raw || "incomplete")
82
137
  end
83
138
 
84
139
  #--
@@ -117,7 +172,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
117
172
  end
118
173
 
119
174
  #--
120
- #: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
175
+ #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
121
176
  def execute_stream(params, yielder)
122
177
  current_state = {
123
178
  tool_info: {}
@@ -149,8 +204,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
149
204
  handle_web_search_status(event, status: "completed", yielder: yielder)
150
205
  when :"response.output_item.done"
151
206
  handle_output_item_done_web_search(event, yielder: yielder) if event.item&.type == :web_search_call
152
- when :"response.completed"
153
- handle_response_completed(event, state: current_state, yielder: yielder)
207
+ when :"response.completed", :"response.incomplete", :"response.failed"
208
+ handle_response_finished(event, state: current_state, yielder: yielder)
154
209
  end
155
210
  end
156
211
  ensure
@@ -162,7 +217,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
162
217
  end
163
218
 
164
219
  #--
165
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
220
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
166
221
  def handle_output_item_added_function_call(event, state:, yielder:)
167
222
  state[:tool_info][event.item.id] = {
168
223
  name: decode_tool_name(event.item.name, tools: @current_tools),
@@ -171,31 +226,31 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
171
226
  end
172
227
 
173
228
  #--
174
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
229
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
175
230
  def handle_output_text_delta(event, state:, yielder:)
176
231
  yielder << Riffer::StreamEvents::TextDelta.new(event.delta)
177
232
  end
178
233
 
179
234
  #--
180
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
235
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
181
236
  def handle_output_text_done(event, state:, yielder:)
182
237
  yielder << Riffer::StreamEvents::TextDone.new(event.text)
183
238
  end
184
239
 
185
240
  #--
186
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
241
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
187
242
  def handle_reasoning_summary_text_delta(event, state:, yielder:)
188
243
  yielder << Riffer::StreamEvents::ReasoningDelta.new(event.delta)
189
244
  end
190
245
 
191
246
  #--
192
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
247
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
193
248
  def handle_reasoning_summary_text_done(event, state:, yielder:)
194
249
  yielder << Riffer::StreamEvents::ReasoningDone.new(event.text)
195
250
  end
196
251
 
197
252
  #--
198
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
253
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
199
254
  def handle_function_call_arguments_delta(event, state:, yielder:)
200
255
  tracked = state[:tool_info][event.item_id] || {}
201
256
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
@@ -206,7 +261,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
206
261
  end
207
262
 
208
263
  #--
209
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
264
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
210
265
  def handle_function_call_arguments_done(event, state:, yielder:)
211
266
  tracked = state[:tool_info][event.item_id] || {}
212
267
  yielder << Riffer::StreamEvents::ToolCallDone.new(
@@ -218,28 +273,27 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
218
273
  end
219
274
 
220
275
  #--
221
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
222
- def handle_response_completed(event, state:, yielder:)
223
- usage = event.response&.usage
276
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
277
+ def handle_response_finished(event, state:, yielder:)
278
+ response = event.response
279
+ return unless response
280
+
281
+ yield_finish_reason(yielder, build_finish_reason(response))
282
+
283
+ usage = response.usage
224
284
  return unless usage
225
285
 
226
- yielder << Riffer::StreamEvents::TokenUsageDone.new(
227
- token_usage: Riffer::Providers::TokenUsage.new(
228
- input_tokens: usage.input_tokens,
229
- output_tokens: usage.output_tokens,
230
- cache_read_tokens: usage.input_tokens_details&.cached_tokens
231
- )
232
- )
286
+ yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(usage))
233
287
  end
234
288
 
235
289
  #--
236
- #: (untyped, status: String, yielder: Enumerator::Yielder) -> void
290
+ #: (untyped, status: String, yielder: Riffer::Providers::_EventSink) -> void
237
291
  def handle_web_search_status(_event, status:, yielder:)
238
292
  yielder << Riffer::StreamEvents::WebSearchStatus.new(status)
239
293
  end
240
294
 
241
295
  #--
242
- #: (untyped, yielder: Enumerator::Yielder) -> void
296
+ #: (untyped, yielder: Riffer::Providers::_EventSink) -> void
243
297
  def handle_output_item_done_web_search(event, yielder:)
244
298
  action = event.item.action
245
299
  case action
@@ -10,6 +10,22 @@ require "json"
10
10
  class Riffer::Providers::OpenRouter < Riffer::Providers::Base
11
11
  BASE_URL = "https://openrouter.ai/api/v1" #: String
12
12
 
13
+ FINISH_REASONS = {
14
+ "stop" => :stop,
15
+ "length" => :length,
16
+ "tool_calls" => :tool_calls,
17
+ "function_call" => :tool_calls,
18
+ "content_filter" => :content_filter,
19
+ "error" => :error
20
+ }.freeze #: Hash[String, Symbol]
21
+
22
+ # The GenAI semconv well-known provider name.
23
+ #--
24
+ #: () -> String
25
+ def self.semconv_provider_name
26
+ "openrouter"
27
+ end
28
+
13
29
  #--
14
30
  #: (?api_key: String?, **untyped) -> void
15
31
  def initialize(api_key: nil, **options)
@@ -70,10 +86,35 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
70
86
  usage = typed_response.usage
71
87
  return nil unless usage
72
88
 
73
- Riffer::Providers::TokenUsage.new(
89
+ build_token_usage(usage)
90
+ end
91
+
92
+ #--
93
+ #: (untyped) -> Riffer::Providers::TokenUsage
94
+ def build_token_usage(usage)
95
+ apply_pricing(Riffer::Providers::TokenUsage.new(
74
96
  input_tokens: usage.prompt_tokens,
75
- output_tokens: usage.completion_tokens
76
- )
97
+ output_tokens: usage.completion_tokens,
98
+ cache_read_tokens: usage.prompt_tokens_details&.cached_tokens
99
+ ))
100
+ end
101
+
102
+ #--
103
+ #: (untyped) -> Riffer::Providers::FinishReason?
104
+ def extract_finish_reason(response)
105
+ typed_response = response #: OpenAI::Models::Chat::ChatCompletion
106
+ build_finish_reason(typed_response.choices.first&.finish_reason)
107
+ end
108
+
109
+ #--
110
+ #: (untyped) -> Riffer::Providers::FinishReason?
111
+ def build_finish_reason(finish_reason)
112
+ return nil unless finish_reason
113
+
114
+ raw = finish_reason.to_s
115
+ return nil if raw.empty?
116
+
117
+ Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(raw, :other), raw: raw)
77
118
  end
78
119
 
79
120
  #--
@@ -105,7 +146,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
105
146
  end
106
147
 
107
148
  #--
108
- #: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
149
+ #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
109
150
  def execute_stream(params, yielder)
110
151
  # OpenRouter omits usage from streams unless explicitly opted in.
111
152
  stream_options = (params[:stream_options] || {}).merge(include_usage: true)
@@ -114,7 +155,8 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
114
155
  state = {
115
156
  text: +"",
116
157
  reasoning: +"",
117
- tool_calls: {}
158
+ tool_calls: {},
159
+ finish_reason: nil
118
160
  } #: Hash[Symbol, untyped]
119
161
 
120
162
  # Use stream_raw (not stream) — the latter yields a higher-level
@@ -139,10 +181,11 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
139
181
 
140
182
  yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
141
183
  yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
184
+ yield_finish_reason(yielder, build_finish_reason(state[:finish_reason]))
142
185
  end
143
186
 
144
187
  #--
145
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
188
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
146
189
  def handle_stream_chunk(chunk, state:, yielder:)
147
190
  typed_chunk = chunk #: OpenAI::Models::Chat::ChatCompletionChunk
148
191
  choice = typed_chunk.choices&.first
@@ -154,22 +197,19 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
154
197
  handle_tool_call_deltas(delta, state: state, yielder: yielder)
155
198
  end
156
199
 
200
+ state[:finish_reason] = choice.finish_reason if choice&.finish_reason
201
+
157
202
  if choice && finish_reason_is_tool_calls?(choice)
158
203
  emit_tool_call_done_events(state: state, yielder: yielder)
159
204
  end
160
205
 
161
206
  return unless typed_chunk.usage
162
207
 
163
- yielder << Riffer::StreamEvents::TokenUsageDone.new(
164
- token_usage: Riffer::Providers::TokenUsage.new(
165
- input_tokens: typed_chunk.usage.prompt_tokens,
166
- output_tokens: typed_chunk.usage.completion_tokens
167
- )
168
- )
208
+ yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(typed_chunk.usage))
169
209
  end
170
210
 
171
211
  #--
172
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
212
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
173
213
  def handle_text_delta(delta, state:, yielder:)
174
214
  typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
175
215
  content = typed_delta.content
@@ -180,7 +220,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
180
220
  end
181
221
 
182
222
  #--
183
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
223
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
184
224
  def handle_reasoning_delta(delta, state:, yielder:)
185
225
  # The openai gem's typed Delta model strips fields not in OpenAI's spec
186
226
  # (so +delta.reasoning+ raises NoMethodError), but the underlying data
@@ -193,7 +233,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
193
233
  end
194
234
 
195
235
  #--
196
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
236
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
197
237
  def handle_tool_call_deltas(delta, state:, yielder:)
198
238
  typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
199
239
  tool_calls = typed_delta.tool_calls
@@ -221,7 +261,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
221
261
  end
222
262
 
223
263
  #--
224
- #: (state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
264
+ #: (state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
225
265
  def emit_tool_call_done_events(state:, yielder:)
226
266
  state[:tool_calls].each do |index, entry|
227
267
  fallback = "tool_#{index}"
@@ -5,6 +5,8 @@
5
5
  module Riffer::Providers::Repository
6
6
  extend self
7
7
 
8
+ # @rbs @key_for: Hash[singleton(Riffer::Providers::Base), Symbol]?
9
+
8
10
  REPO = {
9
11
  amazon_bedrock: -> { Riffer::Providers::AmazonBedrock },
10
12
  anthropic: -> { Riffer::Providers::Anthropic },
@@ -22,4 +24,11 @@ module Riffer::Providers::Repository
22
24
  def find(identifier)
23
25
  REPO.fetch(identifier.to_sym, nil)&.call
24
26
  end
27
+
28
+ # Returns the registry identifier for a provider class, or nil when unregistered.
29
+ #--
30
+ #: (singleton(Riffer::Providers::Base)) -> Symbol?
31
+ def key_for(provider_class)
32
+ (@key_for ||= REPO.to_h { |key, factory| [factory.call, key] })[provider_class]
33
+ end
25
34
  end
@@ -1,27 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Represents token usage data from an LLM API call.
4
+ # Normalized token usage for an LLM API call. Buckets carry the same
5
+ # meaning for every provider.
5
6
  class Riffer::Providers::TokenUsage
6
- # Number of tokens in the input/prompt.
7
+ # Number of tokens entering the context window, including cache reads and writes.
7
8
  attr_reader :input_tokens #: Integer
8
9
 
9
- # Number of tokens in the output/response.
10
+ # Number of tokens generated by the model, including reasoning/thinking tokens.
10
11
  attr_reader :output_tokens #: Integer
11
12
 
12
- # Number of tokens written to cache, when the provider reports it.
13
+ # Subset of +input_tokens+ written to the provider's prompt cache, when the provider reports it.
13
14
  attr_reader :cache_write_tokens #: Integer?
14
15
 
15
- # Number of tokens read from cache, when the provider reports it.
16
+ # Subset of +input_tokens+ read from the provider's prompt cache, when the provider reports it.
16
17
  attr_reader :cache_read_tokens #: Integer?
17
18
 
19
+ # Cost of the call, set when the model is priced. For observability, not billing.
20
+ attr_reader :cost #: Float?
21
+
18
22
  #--
19
- #: (input_tokens: Integer, output_tokens: Integer, ?cache_write_tokens: Integer?, ?cache_read_tokens: Integer?) -> void
20
- def initialize(input_tokens:, output_tokens:, cache_write_tokens: nil, cache_read_tokens: nil)
23
+ #: (input_tokens: Integer, output_tokens: Integer, ?cache_write_tokens: Integer?, ?cache_read_tokens: Integer?, ?cost: Float?) -> void
24
+ def initialize(input_tokens:, output_tokens:, cache_write_tokens: nil, cache_read_tokens: nil, cost: nil)
21
25
  @input_tokens = input_tokens
22
26
  @output_tokens = output_tokens
23
27
  @cache_write_tokens = cache_write_tokens
24
28
  @cache_read_tokens = cache_read_tokens
29
+ @cost = cost
25
30
  end
26
31
 
27
32
  # Returns the total number of tokens (input + output).
@@ -41,17 +46,19 @@ class Riffer::Providers::TokenUsage
41
46
  input_tokens: input_tokens + other.input_tokens,
42
47
  output_tokens: output_tokens + other.output_tokens,
43
48
  cache_write_tokens: add_nullable(cache_write_tokens, other.cache_write_tokens),
44
- cache_read_tokens: add_nullable(cache_read_tokens, other.cache_read_tokens)
49
+ cache_read_tokens: add_nullable(cache_read_tokens, other.cache_read_tokens),
50
+ cost: add_cost(cost, other.cost)
45
51
  )
46
52
  end
47
53
 
48
- # Converts the token usage to a hash; cache tokens are omitted when nil.
54
+ # Converts the token usage to a hash; cache tokens and cost are omitted when nil.
49
55
  #--
50
- #: () -> Hash[Symbol, Integer]
56
+ #: () -> Hash[Symbol, (Integer | Float)]
51
57
  def to_h
52
- hash = {input_tokens: input_tokens, output_tokens: output_tokens}
58
+ hash = {input_tokens: input_tokens, output_tokens: output_tokens} #: Hash[Symbol, (Integer | Float)]
53
59
  hash[:cache_write_tokens] = cache_write_tokens if cache_write_tokens
54
60
  hash[:cache_read_tokens] = cache_read_tokens if cache_read_tokens
61
+ hash[:cost] = cost if cost
55
62
  hash
56
63
  end
57
64
 
@@ -63,4 +70,13 @@ class Riffer::Providers::TokenUsage
63
70
  return nil if a.nil? && b.nil?
64
71
  (a || 0) + (b || 0)
65
72
  end
73
+
74
+ # nil is absorbing, not zero: one unpriced call makes the run total nil
75
+ # rather than silently under-reporting.
76
+ #--
77
+ #: (Float?, Float?) -> Float?
78
+ def add_cost(a, b)
79
+ return nil if a.nil? || b.nil?
80
+ a + b
81
+ end
66
82
  end
@@ -13,16 +13,25 @@ class Riffer::Skills::ActivateTool < Riffer::Tool
13
13
  required :name, String, description: "The skill name to activate"
14
14
  end
15
15
 
16
- # Activates a skill by name and returns its body.
16
+ # Activates a skill by name and returns its wrapped body, or a short pointer when the skill is already active.
17
17
  #--
18
18
  #: (context: Riffer::Agent::Context?, name: String) -> Riffer::Tools::Response
19
19
  def call(context:, name:)
20
20
  skills_context = context&.skills
21
21
  return error("Skills not configured") unless skills_context
22
22
  return error("Unknown skill: '#{name}'") unless skills_context.model_invocable?(name)
23
+ return text(already_active_message(name)) if skills_context.activated?(name)
23
24
 
24
- text(skills_context.activate(name))
25
+ text(skills_context.activation_prompt(name))
25
26
  rescue Riffer::ArgumentError => e
26
27
  error(e.message)
27
28
  end
29
+
30
+ private
31
+
32
+ #--
33
+ #: (String) -> String
34
+ def already_active_message(name)
35
+ "Skill '#{name}' is already active. Its instructions are already available in your context."
36
+ end
28
37
  end
@@ -21,4 +21,19 @@ class Riffer::Skills::Adapter
21
21
  def render_catalog(skills)
22
22
  raise NotImplementedError, "#{self.class} must implement #render_catalog"
23
23
  end
24
+
25
+ # Renders an activated skill body wrapped in identifying tags.
26
+ #--
27
+ #: (Riffer::Skills::Frontmatter, String) -> String
28
+ def render_activation(skill, body)
29
+ %(<skill_content name="#{skill.name}">\n#{body}\n</skill_content>)
30
+ end
31
+
32
+ # The behavioral instructions rendered alongside the catalog.
33
+ #--
34
+ #: () -> String
35
+ def catalog_instructions
36
+ "When a user's request matches a skill description below, call the `#{skill_activate_tool.name}` tool with the skill name. After activation, follow the skill's instructions. " \
37
+ "If a skill's instructions already appear in your context (inside <skill_content> tags), follow them instead of activating the skill again."
38
+ end
24
39
  end