riffer 0.47.2 → 0.48.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +23 -0
  4. data/docs/AGENTS.md +37 -3
  5. data/docs/AGENT_LIFECYCLE.md +1 -0
  6. data/docs/CONFIGURATION.md +1 -1
  7. data/docs/EVALS.md +2 -1
  8. data/docs/MCP.md +0 -4
  9. data/docs/MESSAGES.md +85 -17
  10. data/docs/STREAM_EVENTS.md +8 -4
  11. data/docs/TRACING.md +1 -1
  12. data/docs/providers/AMAZON_BEDROCK.md +1 -1
  13. data/docs/providers/CUSTOM_PROVIDERS.md +56 -4
  14. data/docs/providers/GEMINI.md +1 -1
  15. data/docs/providers/MOCK_PROVIDER.md +17 -0
  16. data/lib/riffer/agent/config.rb +30 -6
  17. data/lib/riffer/agent/response.rb +7 -0
  18. data/lib/riffer/agent/run.rb +24 -8
  19. data/lib/riffer/agent/session.rb +1 -0
  20. data/lib/riffer/agent.rb +29 -5
  21. data/lib/riffer/config.rb +1 -1
  22. data/lib/riffer/evals/evaluator.rb +29 -2
  23. data/lib/riffer/evals/judge.rb +11 -5
  24. data/lib/riffer/files/resolver.rb +3 -3
  25. data/lib/riffer/helpers/deep_dup.rb +42 -0
  26. data/lib/riffer/messages/assistant/reasoning_part.rb +90 -0
  27. data/lib/riffer/messages/assistant/tool_call.rb +63 -0
  28. data/lib/riffer/messages/assistant.rb +50 -2
  29. data/lib/riffer/messages/base.rb +5 -21
  30. data/lib/riffer/messages/system.rb +10 -0
  31. data/lib/riffer/messages/tool.rb +17 -0
  32. data/lib/riffer/messages/{file_part.rb → user/file_part.rb} +15 -11
  33. data/lib/riffer/messages/user.rb +13 -2
  34. data/lib/riffer/params/param.rb +12 -0
  35. data/lib/riffer/params.rb +9 -0
  36. data/lib/riffer/providers/amazon_bedrock.rb +5 -4
  37. data/lib/riffer/providers/anthropic.rb +2 -2
  38. data/lib/riffer/providers/base.rb +27 -8
  39. data/lib/riffer/providers/gemini.rb +2 -2
  40. data/lib/riffer/providers/mock.rb +18 -2
  41. data/lib/riffer/providers/open_ai.rb +6 -5
  42. data/lib/riffer/providers/open_router.rb +6 -5
  43. data/lib/riffer/providers/token_usage.rb +16 -0
  44. data/lib/riffer/skills/config.rb +12 -0
  45. data/lib/riffer/stream_events/reasoning_done.rb +10 -8
  46. data/lib/riffer/tools/runtime.rb +3 -4
  47. data/lib/riffer/tracing/capture.rb +2 -4
  48. data/lib/riffer/version.rb +1 -1
  49. data/sig/generated/riffer/agent/config.rbs +19 -5
  50. data/sig/generated/riffer/agent/response.rbs +6 -1
  51. data/sig/generated/riffer/agent/run.rbs +15 -8
  52. data/sig/generated/riffer/agent.rbs +25 -9
  53. data/sig/generated/riffer/config.rbs +1 -1
  54. data/sig/generated/riffer/evals/evaluator.rbs +20 -1
  55. data/sig/generated/riffer/evals/judge.rbs +5 -2
  56. data/sig/generated/riffer/files/resolver.rbs +6 -6
  57. data/sig/generated/riffer/helpers/deep_dup.rbs +21 -0
  58. data/sig/generated/riffer/messages/assistant/reasoning_part.rbs +58 -0
  59. data/sig/generated/riffer/messages/assistant/tool_call.rbs +42 -0
  60. data/sig/generated/riffer/messages/assistant.rbs +24 -10
  61. data/sig/generated/riffer/messages/system.rbs +6 -0
  62. data/sig/generated/riffer/messages/tool.rbs +6 -0
  63. data/sig/generated/riffer/messages/{file_part.rbs → user/file_part.rbs} +11 -6
  64. data/sig/generated/riffer/messages/user.rbs +9 -3
  65. data/sig/generated/riffer/params/param.rbs +7 -0
  66. data/sig/generated/riffer/params.rbs +6 -0
  67. data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -4
  68. data/sig/generated/riffer/providers/anthropic.rbs +2 -2
  69. data/sig/generated/riffer/providers/base.rbs +22 -10
  70. data/sig/generated/riffer/providers/gemini.rbs +4 -4
  71. data/sig/generated/riffer/providers/mock.rbs +7 -2
  72. data/sig/generated/riffer/providers/open_ai.rbs +4 -4
  73. data/sig/generated/riffer/providers/open_router.rbs +4 -4
  74. data/sig/generated/riffer/providers/token_usage.rbs +6 -0
  75. data/sig/generated/riffer/skills/config.rbs +9 -0
  76. data/sig/generated/riffer/stream_events/reasoning_done.rbs +8 -6
  77. data/sig/generated/riffer/tools/runtime.rbs +2 -2
  78. data/sig/generated/riffer/tracing/capture.rbs +4 -4
  79. data/sig/manual/riffer/helpers/deep_dup.rbs +5 -0
  80. metadata +10 -3
@@ -15,27 +15,11 @@ class Riffer::Messages::Base
15
15
  raise Riffer::ArgumentError, "Message hash must include a 'role' key" if msg[:role].nil? || msg[:role].empty?
16
16
 
17
17
  case msg[:role].to_sym
18
- when :user
19
- files = (msg[:files] || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
20
- Riffer::Messages::User.new(msg[:content], id: msg[:id], files: files)
21
- when :assistant
22
- tool_calls = (msg[:tool_calls] || []).map do |tc|
23
- tc.is_a?(Riffer::Messages::Assistant::ToolCall) ? tc : Riffer::Messages::Assistant::ToolCall.new(**tc)
24
- end
25
- Riffer::Messages::Assistant.new(
26
- msg[:content],
27
- id: msg[:id],
28
- tool_calls: tool_calls,
29
- structured_output: msg[:structured_output],
30
- finish_reason: msg[:finish_reason]&.to_sym,
31
- finish_reason_raw: msg[:finish_reason_raw],
32
- )
33
- when :system
34
- Riffer::Messages::System.new(msg[:content], id: msg[:id])
35
- when :tool
36
- Riffer::Messages::Tool.new(msg[:content], id: msg[:id], tool_call_id: msg[:tool_call_id], name: msg[:name])
37
- else
38
- raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
18
+ when :user then Riffer::Messages::User.from_hash(msg)
19
+ when :assistant then Riffer::Messages::Assistant.from_hash(msg)
20
+ when :system then Riffer::Messages::System.from_hash(msg)
21
+ when :tool then Riffer::Messages::Tool.from_hash(msg)
22
+ else raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
39
23
  end
40
24
  end
41
25
 
@@ -3,6 +3,16 @@
3
3
 
4
4
  # Represents a system message (instructions) in a conversation.
5
5
  class Riffer::Messages::System < Riffer::Messages::Base
6
+ # Builds a System message from a hash, or returns +msg+ unchanged when it is
7
+ # already a System message.
8
+ #--
9
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::System)) -> Riffer::Messages::System
10
+ def self.from_hash(msg)
11
+ return msg if msg.is_a?(Riffer::Messages::System)
12
+
13
+ new(msg[:content], id: msg[:id])
14
+ end
15
+
6
16
  #--
7
17
  #: () -> Symbol
8
18
  def role
@@ -3,6 +3,23 @@
3
3
 
4
4
  # Represents a tool execution result in a conversation.
5
5
  class Riffer::Messages::Tool < Riffer::Messages::Base
6
+ # Builds a Tool message from a hash, or returns +msg+ unchanged when it is
7
+ # already a Tool message.
8
+ #--
9
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::Tool)) -> Riffer::Messages::Tool
10
+ def self.from_hash(msg)
11
+ return msg if msg.is_a?(Riffer::Messages::Tool)
12
+
13
+ new(
14
+ msg[:content],
15
+ id: msg[:id],
16
+ tool_call_id: msg[:tool_call_id],
17
+ name: msg[:name],
18
+ error: msg[:error],
19
+ error_type: msg[:error_type]&.to_sym,
20
+ )
21
+ end
22
+
6
23
  # The ID of the tool call this result responds to.
7
24
  attr_reader :tool_call_id #: String # @dynamic tool_call_id
8
25
 
@@ -6,7 +6,7 @@ require "uri"
6
6
 
7
7
  # Represents a file attachment (image or document) — from a URL (+from_url+) or
8
8
  # raw base64 data (+new+).
9
- class Riffer::Messages::FilePart
9
+ class Riffer::Messages::User::FilePart
10
10
  # @rbs @url_string: String?
11
11
  # @rbs @data: String?
12
12
  # @rbs @downloaded_data: String?
@@ -62,23 +62,18 @@ class Riffer::Messages::FilePart
62
62
  # Creates a FilePart from a URL, detecting +media_type+ from the path
63
63
  # extension when omitted. Raises Riffer::ArgumentError if it can't be detected.
64
64
  #--
65
- #: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::FilePart
65
+ #: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::User::FilePart
66
66
  def self.from_url(url, media_type: nil, filename: nil, sha256: nil)
67
- unless media_type
68
- ext = ::File.extname(URI.parse(url).path.to_s).downcase
69
- media_type = MEDIA_TYPES.fetch(ext) { raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" }
70
- end
71
-
72
- new(url: url, media_type: media_type, filename: filename, sha256: sha256)
67
+ new(url: url, media_type: media_type || detect_media_type(url), filename: filename, sha256: sha256)
73
68
  end
74
69
 
75
70
  # Builds a FilePart from a +{url:, media_type:}+ or +{data:, media_type:}+ hash,
76
71
  # or returns +file+ unchanged when it is already a FilePart. Raises
77
72
  # Riffer::ArgumentError on an invalid hash.
78
73
  #--
79
- #: ((Hash[Symbol, untyped] | Riffer::Messages::FilePart)) -> Riffer::Messages::FilePart
74
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::User::FilePart)) -> Riffer::Messages::User::FilePart
80
75
  def self.from_hash(file)
81
- return file if file.is_a?(Riffer::Messages::FilePart)
76
+ return file if file.is_a?(Riffer::Messages::User::FilePart)
82
77
 
83
78
  url = file[:url]
84
79
  data = file[:data]
@@ -87,7 +82,7 @@ class Riffer::Messages::FilePart
87
82
  sha256 = file[:sha256]
88
83
 
89
84
  if url
90
- from_url(url, media_type: media_type, filename: filename, sha256: sha256)
85
+ new(url: url, data: data, media_type: media_type || detect_media_type(url), filename: filename, sha256: sha256)
91
86
  elsif data && media_type
92
87
  new(data: data, media_type: media_type, filename: filename, sha256: sha256)
93
88
  else
@@ -95,6 +90,15 @@ class Riffer::Messages::FilePart
95
90
  end
96
91
  end
97
92
 
93
+ # Raises Riffer::ArgumentError when the URL's extension isn't a known media type.
94
+ #--
95
+ #: (String) -> String
96
+ def self.detect_media_type(url)
97
+ ext = ::File.extname(URI.parse(url).path.to_s).downcase
98
+ MEDIA_TYPES.fetch(ext) { raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" }
99
+ end
100
+ private_class_method :detect_media_type
101
+
98
102
  # The base64-encoded contents - caller-supplied, or filled in by the file
99
103
  # resolver after a download. Nil for a URL source riffer hasn't fetched.
100
104
  #--
@@ -3,11 +3,22 @@
3
3
 
4
4
  # Represents a user message in a conversation.
5
5
  class Riffer::Messages::User < Riffer::Messages::Base
6
+ # Builds a User message from a hash, or returns +msg+ unchanged when it is
7
+ # already a User message. Raises Riffer::ArgumentError on an invalid file.
8
+ #--
9
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::User)) -> Riffer::Messages::User
10
+ def self.from_hash(msg)
11
+ return msg if msg.is_a?(Riffer::Messages::User)
12
+
13
+ files = (msg[:files] || []).map { |f| Riffer::Messages::User::FilePart.from_hash(f) }
14
+ new(msg[:content], id: msg[:id], files: files)
15
+ end
16
+
6
17
  # File attachments for this message.
7
- attr_reader :files #: Array[Riffer::Messages::FilePart] # @dynamic files
18
+ attr_reader :files #: Array[Riffer::Messages::User::FilePart] # @dynamic files
8
19
 
9
20
  #--
10
- #: (String, ?id: String?, ?files: Array[Riffer::Messages::FilePart]) -> void
21
+ #: (String, ?id: String?, ?files: Array[Riffer::Messages::User::FilePart]) -> void
11
22
  def initialize(content, id: nil, files: [])
12
23
  super(content, id: id)
13
24
  @files = files
@@ -181,6 +181,18 @@ class Riffer::Params::Param
181
181
 
182
182
  private
183
183
 
184
+ # +dup+ would leave the copy sharing this one's nested Params, enum list and
185
+ # default, so defining a parameter or editing either value on one would reach
186
+ # the other.
187
+ #--
188
+ #: (Riffer::Params::Param) -> void
189
+ def initialize_copy(source)
190
+ super
191
+ @nested_params = source.nested_params&.dup
192
+ @enum = Riffer::Helpers::DeepDup.call(source.enum)
193
+ @default = Riffer::Helpers::DeepDup.call(source.default)
194
+ end
195
+
184
196
  #--
185
197
  #: () -> void
186
198
  def validate_strict_shape!
data/lib/riffer/params.rb CHANGED
@@ -151,6 +151,15 @@ class Riffer::Params
151
151
 
152
152
  private
153
153
 
154
+ # +dup+ would leave the copy sharing this one's parameters, so a caller
155
+ # defining a parameter on either would reach the other.
156
+ #--
157
+ #: (Riffer::Params) -> void
158
+ def initialize_copy(source)
159
+ super
160
+ @parameters = source.parameters.map(&:dup)
161
+ end
162
+
154
163
  #--
155
164
  #: (Module, Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
156
165
  def build_nested(type, of, &block)
@@ -47,7 +47,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
47
47
  end
48
48
 
49
49
  #--
50
- #: (Riffer::Messages::FilePart) -> Symbol
50
+ #: (Riffer::Messages::User::FilePart) -> Symbol
51
51
  def file_delivery(file)
52
52
  file.url&.start_with?("s3://") ? :url : :bytes
53
53
  end
@@ -98,8 +98,9 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
98
98
 
99
99
  # requestMetadata is a flat String=>String map used to filter invocation
100
100
  # logs; every tag (including the reserved user_id) rides along, since
101
- # Converse has no dedicated end-user field.
102
- params[:request_metadata] = tags unless tags.empty?
101
+ # Converse has no dedicated end-user field. Merged over any request_metadata
102
+ # set in model_options; a tag wins on a shared key.
103
+ params[:request_metadata] = (params[:request_metadata] || {}).merge(tags) unless tags.empty?
103
104
 
104
105
  if tools && !tools.empty?
105
106
  params[:tool_config] = {
@@ -449,7 +450,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
449
450
  end
450
451
 
451
452
  #--
452
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
453
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
453
454
  def convert_file_part_to_bedrock_format(file)
454
455
  format = bedrock_format(file.media_type)
455
456
 
@@ -338,7 +338,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
338
338
  #--
339
339
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
340
340
  def handle_content_block_stop_thinking(_event, state:, yielder:)
341
- yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning])
341
+ yield_reasoning_done(yielder, state[:reasoning])
342
342
  state[:reasoning] = nil
343
343
  end
344
344
 
@@ -444,7 +444,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
444
444
  end
445
445
 
446
446
  #--
447
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
447
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
448
448
  def convert_file_part_to_anthropic_format(file)
449
449
  type = file.image? ? "image" : "document"
450
450
 
@@ -40,7 +40,7 @@ class Riffer::Providers::Base
40
40
  # Generates text using the provider.
41
41
  #
42
42
  #--
43
- #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, **untyped) -> Riffer::Messages::Assistant
43
+ #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?, **untyped) -> Riffer::Messages::Assistant
44
44
  def generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
45
45
  validate_input!(prompt: prompt, system: system, messages: messages)
46
46
  @current_tools = options[:tools] || [] #: Array[singleton(Riffer::Tool)]
@@ -56,6 +56,7 @@ class Riffer::Providers::Base
56
56
 
57
57
  content = extract_content(response)
58
58
  tool_calls = extract_tool_calls(response)
59
+ reasoning = extract_reasoning(response)
59
60
  token_usage = extract_token_usage(response)
60
61
  finish_reason = extract_finish_reason(response)
61
62
  structured_output = parse_structured_output(content) if options[:structured_output] && tool_calls.empty?
@@ -67,6 +68,7 @@ class Riffer::Providers::Base
67
68
  Riffer::Messages::Assistant.new(
68
69
  content,
69
70
  tool_calls: tool_calls,
71
+ reasoning: reasoning,
70
72
  token_usage: token_usage,
71
73
  structured_output: structured_output,
72
74
  finish_reason: finish_reason&.reason,
@@ -78,7 +80,7 @@ class Riffer::Providers::Base
78
80
  # Streams text from the provider.
79
81
  #
80
82
  #--
81
- #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
83
+ #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
82
84
  def stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
83
85
  validate_input!(prompt: prompt, system: system, messages: messages)
84
86
  @current_tools = options[:tools] || [] #: Array[singleton(Riffer::Tool)]
@@ -105,7 +107,7 @@ class Riffer::Providers::Base
105
107
  end
106
108
 
107
109
  #--
108
- #: (Riffer::Messages::FilePart) -> Symbol
110
+ #: (Riffer::Messages::User::FilePart) -> Symbol
109
111
  def file_delivery(_file)
110
112
  :url
111
113
  end
@@ -224,6 +226,14 @@ class Riffer::Providers::Base
224
226
  nil
225
227
  end
226
228
 
229
+ # Defaults to no parts rather than raising — reasoning parts are optional, so
230
+ # providers that don't expose replayable reasoning stay valid.
231
+ #--
232
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
233
+ def extract_reasoning(_response)
234
+ []
235
+ end
236
+
227
237
  #--
228
238
  #: (untyped) -> String
229
239
  def extract_content(response)
@@ -348,6 +358,15 @@ class Riffer::Providers::Base
348
358
  Riffer.config.tracing.capture_messages && span.recording?
349
359
  end
350
360
 
361
+ # Wraps reasoning text that an adapter cannot yet replay in a +:text+ part
362
+ # with no +format+, so it persists for display but is never sent back.
363
+ #--
364
+ #: (Riffer::Providers::_EventSink, String) -> void
365
+ def yield_reasoning_done(yielder, text)
366
+ part = Riffer::Messages::Assistant::ReasoningPart.new(type: :text, text: text)
367
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
368
+ end
369
+
351
370
  #--
352
371
  #: (Riffer::Providers::_EventSink, Riffer::Providers::FinishReason?) -> void
353
372
  def yield_finish_reason(yielder, finish_reason)
@@ -368,11 +387,11 @@ class Riffer::Providers::Base
368
387
  end
369
388
 
370
389
  #--
371
- #: ((String | Hash[String, untyped])?) -> Hash[String, untyped]
390
+ #: (String) -> Hash[String, untyped]
372
391
  def parse_tool_arguments(arguments)
373
- return {} if arguments.nil? || arguments.empty?
392
+ return {} if arguments.empty?
374
393
 
375
- arguments.is_a?(String) ? JSON.parse(arguments) : arguments
394
+ JSON.parse(arguments)
376
395
  end
377
396
 
378
397
  #--
@@ -397,7 +416,7 @@ class Riffer::Providers::Base
397
416
  end
398
417
 
399
418
  #--
400
- #: (prompt: String?, system: String?, messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Array[Riffer::Messages::Base]
419
+ #: (prompt: String?, system: String?, messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?) -> Array[Riffer::Messages::Base]
401
420
  def normalize_messages(prompt:, system:, messages:, files: nil)
402
421
  if messages && files && !files.empty?
403
422
  raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead"
@@ -407,7 +426,7 @@ class Riffer::Providers::Base
407
426
 
408
427
  result = [] #: Array[Riffer::Messages::Base]
409
428
  result << Riffer::Messages::System.new(system) if system
410
- file_parts = (files || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
429
+ file_parts = (files || []).map { |f| Riffer::Messages::User::FilePart.from_hash(f) }
411
430
  prompt_text = prompt #: String
412
431
  result << Riffer::Messages::User.new(prompt_text, files: file_parts)
413
432
  result
@@ -37,7 +37,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
37
37
  end
38
38
 
39
39
  #--
40
- #: (Riffer::Messages::FilePart) -> Symbol
40
+ #: (Riffer::Messages::User::FilePart) -> Symbol
41
41
  def file_delivery(_file)
42
42
  :base64
43
43
  end
@@ -287,7 +287,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
287
287
  end
288
288
 
289
289
  #--
290
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
290
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
291
291
  def convert_file_part_to_gemini_format(file)
292
292
  { inlineData: { mimeType: file.media_type, data: file.data } }
293
293
  end
@@ -54,15 +54,17 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
54
54
  # provider.stub_response("Final response",
55
55
  # token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
56
56
  # provider.stub_response("Truncated...", finish_reason: :length)
57
+ # provider.stub_response("Answer", reasoning: [{type: :text, text: "Thinking...", format: "mock-v1"}])
57
58
  #
58
59
  #--
59
- #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?, ?finish_reason: Symbol?) -> void
60
- def stub_response(content, tool_calls: [], token_usage: nil, finish_reason: nil)
60
+ #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?, ?finish_reason: Symbol?, ?reasoning: Array[Hash[Symbol, untyped] | Riffer::Messages::Assistant::ReasoningPart]) -> void
61
+ def stub_response(content, tool_calls: [], token_usage: nil, finish_reason: nil, reasoning: [])
61
62
  @stubbed_responses << normalize_response(
62
63
  content: content,
63
64
  tool_calls: tool_calls,
64
65
  token_usage: token_usage,
65
66
  finish_reason: finish_reason,
67
+ reasoning: reasoning,
66
68
  )
67
69
  end
68
70
 
@@ -92,6 +94,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
92
94
  role: response[:role] || "assistant",
93
95
  content: response[:content] || "",
94
96
  tool_calls: formatted_tool_calls,
97
+ reasoning: (response[:reasoning] || []).map { |part| Riffer::Messages::Assistant::ReasoningPart.from_hash(part) },
95
98
  token_usage: response[:token_usage],
96
99
  finish_reason: response[:finish_reason] || (formatted_tool_calls.empty? ? :stop : :tool_calls),
97
100
  }
@@ -129,6 +132,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
129
132
  reason ? Riffer::Providers::FinishReason.new(reason: reason) : nil
130
133
  end
131
134
 
135
+ #--
136
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
137
+ def extract_reasoning(response)
138
+ response[:reasoning] || []
139
+ end
140
+
132
141
  #--
133
142
  #: (untyped) -> String
134
143
  def extract_content(response)
@@ -149,6 +158,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
149
158
  tool_calls = response[:tool_calls] || []
150
159
  token_usage = response[:token_usage]
151
160
  web_search = response[:web_search]
161
+ reasoning = response[:reasoning] || []
152
162
 
153
163
  if web_search
154
164
  yielder << Riffer::StreamEvents::WebSearchStatus.new("in_progress")
@@ -161,6 +171,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
161
171
  )
162
172
  end
163
173
 
174
+ reasoning.each do |part|
175
+ text = part.text
176
+ yielder << Riffer::StreamEvents::ReasoningDelta.new(text) if text
177
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
178
+ end
179
+
164
180
  unless full_content.empty?
165
181
  content_parts = full_content.split(". ").map { |part| part + (part.end_with?(".") ? "" : ".") }
166
182
  content_parts.each do |part|
@@ -35,7 +35,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
35
35
  end
36
36
 
37
37
  #--
38
- #: (Riffer::Messages::FilePart) -> Symbol
38
+ #: (Riffer::Messages::User::FilePart) -> Symbol
39
39
  def file_delivery(file)
40
40
  file.image? ? :url : :base64
41
41
  end
@@ -80,7 +80,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
80
80
  } #: Hash[Symbol, untyped]
81
81
 
82
82
  unless tags.empty?
83
- params[:metadata] = tags
83
+ # Merged over any metadata set in model_options; a tag wins on a shared key.
84
+ params[:metadata] = (params[:metadata] || {}).merge(tags)
84
85
  # The reserved user_id also maps to the native safety identifier while
85
86
  # staying in metadata as an ordinary tag.
86
87
  user_id = tags["user_id"]
@@ -296,7 +297,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
296
297
  #--
297
298
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
298
299
  def handle_reasoning_summary_text_done(event, state:, yielder:)
299
- yielder << Riffer::StreamEvents::ReasoningDone.new(event.text)
300
+ yield_reasoning_done(yielder, event.text)
300
301
  end
301
302
 
302
303
  #--
@@ -402,7 +403,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
402
403
  type: "function_call",
403
404
  call_id: tc.call_id,
404
405
  name: encode_tool_name(tc.name),
405
- arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json,
406
+ arguments: tc.arguments,
406
407
  }
407
408
  end
408
409
  items
@@ -410,7 +411,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
410
411
  end
411
412
 
412
413
  #--
413
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
414
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
414
415
  def convert_file_part_to_openai_format(file)
415
416
  if file.image?
416
417
  image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
@@ -34,7 +34,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
34
34
  end
35
35
 
36
36
  #--
37
- #: (Riffer::Messages::FilePart) -> Symbol
37
+ #: (Riffer::Messages::User::FilePart) -> Symbol
38
38
  def file_delivery(file)
39
39
  file.image? ? :url : :base64
40
40
  end
@@ -74,7 +74,8 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
74
74
  } #: Hash[Symbol, untyped]
75
75
 
76
76
  unless tags.empty?
77
- params[:metadata] = tags
77
+ # Merged over any metadata set in model_options; a tag wins on a shared key.
78
+ params[:metadata] = (params[:metadata] || {}).merge(tags)
78
79
  # OpenRouter exposes the legacy Chat Completions user field rather than
79
80
  # safety_identifier; the reserved user_id maps there and stays in metadata.
80
81
  user = tags["user_id"]
@@ -223,7 +224,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
223
224
  emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?
224
225
 
225
226
  yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
226
- yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
227
+ yield_reasoning_done(yielder, state[:reasoning]) unless state[:reasoning].empty?
227
228
  yield_finish_reason(yielder, build_finish_reason(state[:finish_reason], native: state[:native_finish_reason]))
228
229
  end
229
230
 
@@ -362,7 +363,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
362
363
  type: "function",
363
364
  function: {
364
365
  name: encode_tool_name(tc.name),
365
- arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json,
366
+ arguments: tc.arguments,
366
367
  },
367
368
  }
368
369
  end
@@ -372,7 +373,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
372
373
  end
373
374
 
374
375
  #--
375
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
376
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
376
377
  def convert_file_part_to_chat_completions_format(file)
377
378
  if file.image?
378
379
  image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
@@ -4,6 +4,22 @@
4
4
  # Normalized token usage for an LLM API call. Buckets carry the same
5
5
  # meaning for every provider.
6
6
  class Riffer::Providers::TokenUsage
7
+ # Builds a TokenUsage from a hash, or returns +usage+ unchanged when it is
8
+ # already a TokenUsage.
9
+ #--
10
+ #: ((Hash[Symbol, untyped] | Riffer::Providers::TokenUsage)) -> Riffer::Providers::TokenUsage
11
+ def self.from_hash(usage)
12
+ return usage if usage.is_a?(Riffer::Providers::TokenUsage)
13
+
14
+ new(
15
+ input_tokens: usage[:input_tokens],
16
+ output_tokens: usage[:output_tokens],
17
+ cache_write_tokens: usage[:cache_write_tokens],
18
+ cache_read_tokens: usage[:cache_read_tokens],
19
+ cost: usage[:cost],
20
+ )
21
+ end
22
+
7
23
  # Number of tokens entering the context window, including cache reads and writes.
8
24
  attr_reader :input_tokens #: Integer # @dynamic input_tokens
9
25
 
@@ -67,4 +67,16 @@ class Riffer::Skills::Config
67
67
 
68
68
  @activate_tool = value
69
69
  end
70
+
71
+ private
72
+
73
+ # +dup+ would leave the copy sharing this one's activation list, so activating
74
+ # a skill on either would reach the other. DeepDup rebuilds the list and
75
+ # returns a Proc as-is, which has nothing to alias.
76
+ #--
77
+ #: (Riffer::Skills::Config) -> void
78
+ def initialize_copy(source)
79
+ super
80
+ @activate = Riffer::Helpers::DeepDup.call(source.activate)
81
+ end
70
82
  end
@@ -1,22 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Represents completed reasoning during streaming; only emitted by providers
5
- # that support reasoning (e.g. OpenAI with the reasoning option).
4
+ # Represents one completed reasoning block during streaming; only emitted by
5
+ # providers that support reasoning (e.g. OpenAI with the reasoning option).
6
6
  class Riffer::StreamEvents::ReasoningDone < Riffer::StreamEvents::Base
7
- # The complete reasoning content.
8
- attr_reader :content #: String # @dynamic content
7
+ # The reasoning block, which the agent loop accumulates onto the assistant
8
+ # message. Its +text+ is the content the preceding ReasoningDelta events
9
+ # added up to.
10
+ attr_reader :part #: Riffer::Messages::Assistant::ReasoningPart # @dynamic part
9
11
 
10
12
  #--
11
- #: (String, ?role: Symbol) -> void
12
- def initialize(content, role: :assistant)
13
+ #: (Riffer::Messages::Assistant::ReasoningPart, ?role: Symbol) -> void
14
+ def initialize(part, role: :assistant)
13
15
  super(role: role)
14
- @content = content
16
+ @part = part
15
17
  end
16
18
 
17
19
  #--
18
20
  #: () -> Hash[Symbol, untyped]
19
21
  def to_h
20
- { role: @role, content: @content }
22
+ { role: @role, part: part.to_h }
21
23
  end
22
24
  end
@@ -101,9 +101,9 @@ class Riffer::Tools::Runtime
101
101
  end
102
102
 
103
103
  #--
104
- #: (String?) -> untyped
104
+ #: (String) -> untyped
105
105
  def parse_arguments(arguments)
106
- return {} if arguments.nil? || arguments.empty?
106
+ return {} if arguments.empty?
107
107
 
108
108
  JSON.parse(arguments, symbolize_names: true)
109
109
  end
@@ -168,8 +168,7 @@ class Riffer::Tools::Runtime
168
168
  def capture_tool_arguments(span, tool_call)
169
169
  return unless capture_tool_content?(span)
170
170
 
171
- arguments = tool_call.arguments
172
- span.set_attribute("gen_ai.tool.call.arguments", arguments) if arguments
171
+ span.set_attribute("gen_ai.tool.call.arguments", tool_call.arguments)
173
172
  end
174
173
 
175
174
  #--
@@ -71,7 +71,7 @@ module Riffer::Tracing::Capture # :nodoc: all
71
71
  end
72
72
 
73
73
  #--
74
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
74
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
75
75
  def file_part(file)
76
76
  part = { type: "file", media_type: file.media_type } #: Hash[Symbol, untyped]
77
77
  part[:name] = file.filename if file.filename
@@ -81,10 +81,8 @@ module Riffer::Tracing::Capture # :nodoc: all
81
81
  # Semconv's tool_call part carries arguments as a JSON object; riffer holds
82
82
  # them as a string — parse so the captured payload isn't double-encoded.
83
83
  #--
84
- #: (untyped) -> untyped
84
+ #: (String) -> untyped
85
85
  def parse_arguments(arguments)
86
- return arguments unless arguments.is_a?(String)
87
-
88
86
  JSON.parse(arguments)
89
87
  rescue JSON::ParserError
90
88
  arguments
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.47.2" #: String
5
+ VERSION = "0.48.0" #: String
6
6
  end