riffer 0.43.0 → 0.45.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +15 -0
  4. data/Steepfile +1 -0
  5. data/docs/AGENTS.md +23 -2
  6. data/docs/CONFIGURATION.md +32 -0
  7. data/docs/MESSAGES.md +22 -10
  8. data/docs/OVERVIEW.md +1 -0
  9. data/docs/TESTING.md +129 -0
  10. data/docs/TOOLS.md +23 -2
  11. data/docs/TRACING.md +1 -1
  12. data/docs/providers/AMAZON_BEDROCK.md +17 -0
  13. data/docs/providers/CUSTOM_PROVIDERS.md +1 -1
  14. data/docs/providers/GEMINI.md +2 -3
  15. data/docs-site/manifest.yml +3 -0
  16. data/lib/riffer/config.rb +94 -0
  17. data/lib/riffer/files/downloader.rb +78 -0
  18. data/lib/riffer/files/resolver.rb +97 -0
  19. data/lib/riffer/files.rb +5 -0
  20. data/lib/riffer/helpers/identifier.rb +11 -3
  21. data/lib/riffer/messages/file_part.rb +61 -9
  22. data/lib/riffer/providers/amazon_bedrock.rb +10 -7
  23. data/lib/riffer/providers/anthropic.rb +4 -0
  24. data/lib/riffer/providers/base.rb +8 -0
  25. data/lib/riffer/providers/finish_reason.rb +1 -1
  26. data/lib/riffer/providers/gemini.rb +16 -6
  27. data/lib/riffer/providers/open_ai.rb +36 -23
  28. data/lib/riffer/providers/open_router.rb +27 -7
  29. data/lib/riffer/registrable.rb +105 -22
  30. data/lib/riffer/testing/minitest.rb +21 -0
  31. data/lib/riffer/testing/rspec.rb +11 -0
  32. data/lib/riffer/testing.rb +130 -0
  33. data/lib/riffer/version.rb +1 -1
  34. data/lib/riffer.rb +31 -0
  35. data/sig/_private/minitest.rbs +9 -0
  36. data/sig/_private/riffer/testing/minitest.rbs +6 -0
  37. data/sig/_private/rspec.rbs +7 -0
  38. data/sig/_private/zeitwerk.rbs +2 -0
  39. data/sig/generated/riffer/config.rbs +64 -0
  40. data/sig/generated/riffer/files/downloader.rbs +16 -0
  41. data/sig/generated/riffer/files/resolver.rbs +36 -0
  42. data/sig/generated/riffer/files.rbs +4 -0
  43. data/sig/generated/riffer/helpers/identifier.rbs +8 -0
  44. data/sig/generated/riffer/messages/file_part.rbs +38 -6
  45. data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -0
  46. data/sig/generated/riffer/providers/base.rbs +4 -0
  47. data/sig/generated/riffer/providers/gemini.rbs +4 -0
  48. data/sig/generated/riffer/providers/open_ai.rbs +12 -3
  49. data/sig/generated/riffer/providers/open_router.rbs +14 -2
  50. data/sig/generated/riffer/registrable.rbs +57 -9
  51. data/sig/generated/riffer/testing/rspec.rbs +2 -0
  52. data/sig/generated/riffer/testing.rbs +75 -0
  53. data/sig/generated/riffer.rbs +32 -0
  54. data/sig/manifest.yaml +1 -0
  55. data/sig/manual/riffer/testing.rbs +5 -0
  56. metadata +31 -1
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "digest"
5
+ require "base64"
6
+
7
+ class Riffer::Files::Resolver
8
+ # @rbs @provider: Riffer::Providers::Base
9
+ # @rbs @config: Riffer::Config::Files
10
+
11
+ #: (provider: Riffer::Providers::Base) -> void
12
+ def initialize(provider:)
13
+ @provider = provider
14
+ @config = Riffer.config.files
15
+ end
16
+
17
+ # Resolves every file in every User message in place - downloading,
18
+ # verifying, and caching as the provider's capability and each file's
19
+ # sha256 require. Raises Riffer::FileError on any file that can't be
20
+ # resolved
21
+ #: (Array[Riffer::Messages::Base]) -> void
22
+ def resolve!(messages)
23
+ files = messages.flat_map do |message|
24
+ next [] unless message.is_a?(Riffer::Messages::User)
25
+
26
+ check_file_count!(message)
27
+ message.files
28
+ end
29
+
30
+ @config.runner.map(files, context: nil) { |file| resolve_file!(file) }
31
+ end
32
+
33
+ private
34
+
35
+ #: (Riffer::Messages::User) -> void
36
+ def check_file_count!(message)
37
+ max = @config.max_per_message
38
+ return if max.nil? || message.files.size <= max
39
+
40
+ raise Riffer::TooManyFilesError, "Too many files specified in user message"
41
+ end
42
+
43
+ #: (Riffer::Messages::FilePart) -> void
44
+ def resolve_file!(file)
45
+ delivery = @provider.file_delivery(file)
46
+ if delivery == :unsupported
47
+ raise Riffer::FileUnsupportedError,
48
+ "Provider does not support user message file attachments"
49
+ end
50
+ return verify_inline!(file) if file.inline_data?
51
+
52
+ case delivery
53
+ when :url
54
+ download!(file, cache: false) if file.sha256
55
+ when :base64
56
+ file.data ? verify_inline!(file) : download!(file, cache: :base64)
57
+ when :bytes
58
+ file.data_bytes ? verify_inline!(file) : download!(file, cache: :bytes)
59
+ else
60
+ raise Riffer::ArgumentError,
61
+ "Unknown file_delivery result #{delivery.inspect} from #{@provider.class}"
62
+ end
63
+ end
64
+
65
+ #: (Riffer::Messages::FilePart) -> void
66
+ def verify_inline!(file)
67
+ return unless file.sha256
68
+
69
+ verify_bytes!(file.data_bytes, file.sha256)
70
+ end
71
+
72
+ # +cache:+ is false for a :url-delivery provider verifying a sha256 — the
73
+ # request still sends the URL, never the downloaded bytes, so caching them
74
+ # would hold memory nothing reads and let later turns skip re-verifying.
75
+ #: (Riffer::Messages::FilePart, cache: (false | Symbol)) -> void
76
+ def download!(file, cache:)
77
+ raise Riffer::FileDownloadsDisabledError, "File attachments are disabled" unless @config.allow_downloads
78
+
79
+ raw = @config.downloader.call(file.url, max_bytes: @config.max_bytes, timeout: @config.timeout)
80
+ verify_bytes!(raw, file.sha256) if file.sha256
81
+
82
+ case cache
83
+ when :base64
84
+ file.cache_downloaded_data(Base64.strict_encode64(raw))
85
+ when :bytes
86
+ file.cache_data_bytes(raw)
87
+ when false
88
+ nil
89
+ end
90
+ end
91
+
92
+ def verify_bytes!(bytes, sha256)
93
+ return if Digest::SHA256.hexdigest(bytes) == sha256
94
+
95
+ raise Riffer::FileChecksumMismatchError, "File checksum mismatch"
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Riffer::Files
5
+ end
@@ -29,13 +29,21 @@ module Riffer::Helpers::Identifier
29
29
  cached = klass.instance_variable_get(:@derived_identifier) #: String?
30
30
  return cached if cached
31
31
 
32
- # Tool classes shadow Module#name with the identifier DSL, so the real
33
- # class-path name must come from Module's own implementation.
34
- real_name = Module.instance_method(:name).bind_call(klass) #: String?
32
+ real_name = real_name(klass)
35
33
  return "" if real_name.nil?
36
34
 
37
35
  derived = derive(real_name)
38
36
  klass.instance_variable_set(:@derived_identifier, derived)
39
37
  derived
40
38
  end
39
+
40
+ # Returns the class-path name of a class or module, or +nil+ when anonymous.
41
+ # Tool classes shadow Module#name with the identifier DSL, so the real name
42
+ # must come from Module's own implementation.
43
+ #
44
+ #--
45
+ #: (Module) -> String?
46
+ def real_name(klass)
47
+ Module.instance_method(:name).bind_call(klass) #: String?
48
+ end
41
49
  end
@@ -8,6 +8,9 @@ require "uri"
8
8
  # raw base64 data (+new+).
9
9
  class Riffer::Messages::FilePart
10
10
  # @rbs @url_string: String?
11
+ # @rbs @data: String?
12
+ # @rbs @downloaded_data: String?
13
+ # @rbs @data_bytes: String?
11
14
 
12
15
  MEDIA_TYPES = {
13
16
  ".jpg" => "image/jpeg",
@@ -23,6 +26,7 @@ class Riffer::Messages::FilePart
23
26
  }.freeze #: Hash[String, String]
24
27
 
25
28
  SUPPORTED_MEDIA_TYPES = MEDIA_TYPES.values.uniq.freeze #: Array[String]
29
+ SHA256_PATTERN = /\A[0-9a-f]{64}\z/i #: Regexp
26
30
 
27
31
  # The MIME type of the file.
28
32
  attr_reader :media_type #: String
@@ -30,17 +34,25 @@ class Riffer::Messages::FilePart
30
34
  # The filename, if available.
31
35
  attr_reader :filename #: String?
32
36
 
37
+ # The expected SHA-256 of the file contents, if the caller supplied one.
38
+ attr_reader :sha256 #: String?
39
+
33
40
  # Raises Riffer::ArgumentError unless +data+ or +url+ is given and
34
41
  # +media_type+ is supported.
35
42
  #--
36
- #: (media_type: String, ?data: String?, ?filename: String?, ?url: String?) -> void
37
- def initialize(media_type:, data: nil, filename: nil, url: nil)
43
+ #: (media_type: String, ?data: String?, ?filename: String?, ?url: String?, ?sha256: String?) -> void
44
+ def initialize(media_type:, data: nil, filename: nil, url: nil, sha256: nil)
38
45
  raise Riffer::ArgumentError, "Either data or url must be provided" if data.nil? && url.nil?
39
46
  unless SUPPORTED_MEDIA_TYPES.include?(media_type)
40
47
  raise Riffer::ArgumentError,
41
48
  "Unsupported media type: #{media_type}"
42
49
  end
50
+ unless sha256.nil? || (sha256.is_a?(String) && sha256.match?(SHA256_PATTERN))
51
+ raise Riffer::ArgumentError,
52
+ "Invalid sha256: #{sha256}"
53
+ end
43
54
 
55
+ @sha256 = sha256&.downcase
44
56
  @data = data
45
57
  @media_type = media_type
46
58
  @filename = filename
@@ -50,15 +62,15 @@ class Riffer::Messages::FilePart
50
62
  # Creates a FilePart from a URL, detecting +media_type+ from the path
51
63
  # extension when omitted. Raises Riffer::ArgumentError if it can't be detected.
52
64
  #--
53
- #: (String, ?media_type: String?) -> Riffer::Messages::FilePart
54
- def self.from_url(url, media_type: nil)
65
+ #: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::FilePart
66
+ def self.from_url(url, media_type: nil, filename: nil, sha256: nil)
55
67
  unless media_type
56
68
  ext = ::File.extname(URI.parse(url).path.to_s).downcase
57
69
  media_type = MEDIA_TYPES[ext]
58
70
  raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" unless media_type
59
71
  end
60
72
 
61
- new(url: url, media_type: media_type)
73
+ new(url: url, media_type: media_type, filename: filename, sha256: sha256)
62
74
  end
63
75
 
64
76
  # Builds a FilePart from a +{url:, media_type:}+ or +{data:, media_type:}+ hash,
@@ -75,18 +87,57 @@ class Riffer::Messages::FilePart
75
87
  data = file[:data]
76
88
  media_type = file[:media_type]
77
89
  filename = file[:filename]
90
+ sha256 = file[:sha256]
78
91
 
79
92
  if url
80
- from_url(url, media_type: media_type)
93
+ from_url(url, media_type: media_type, filename: filename, sha256: sha256)
81
94
  elsif data && media_type
82
- new(data: data, media_type: media_type, filename: filename)
95
+ new(data: data, media_type: media_type, filename: filename, sha256: sha256)
83
96
  else
84
97
  raise Riffer::ArgumentError, "File hash must include :url or :data with :media_type"
85
98
  end
86
99
  end
87
100
 
88
- # Returns the base64-encoded data, or nil for URL-only sources.
89
- attr_reader :data #: String?
101
+ # The base64-encoded contents - caller-supplied, or filled in by the file
102
+ # resolver after a download. Nil for a URL source riffer hasn't fetched.
103
+ #--
104
+ #: () -> String?
105
+ def data
106
+ @data || @downloaded_data
107
+ end
108
+
109
+ #: () -> String?
110
+ def data_bytes
111
+ return @data_bytes if @data_bytes
112
+
113
+ encoded = data
114
+ return nil unless encoded
115
+
116
+ @data_bytes = Base64.strict_decode64(encoded.gsub(/\s/, ""))
117
+ rescue ArgumentError
118
+ raise Riffer::FileEncodingError, "Invalid base64 data"
119
+ end
120
+
121
+ #: (String) -> void
122
+ def cache_data_bytes(bytes)
123
+ @data_bytes = bytes
124
+ end
125
+
126
+ # Whether data was supplied directly, as opposed to filled in later by the
127
+ # file resolver after a download
128
+ #: () -> bool
129
+ def inline_data?
130
+ !@data.nil?
131
+ end
132
+
133
+ # Caches bytes fetched for a URL source. Deliberately absent from +to_h+:
134
+ # the agent loop re-sends history on every turn, so the cache saves refreshing
135
+ # the same file, while persisted history stays free of megabytes of base64
136
+ #--
137
+ #: (String) -> void
138
+ def cache_downloaded_data(data)
139
+ @downloaded_data = data
140
+ end
90
141
 
91
142
  # Returns the URL if the source was a URL, nil otherwise.
92
143
  #
@@ -129,6 +180,7 @@ class Riffer::Messages::FilePart
129
180
  hash[:data] = @data if @data
130
181
  hash[:url] = @url_string if @url_string
131
182
  hash[:filename] = filename if filename
183
+ hash[:sha256] = sha256 if sha256
132
184
  hash
133
185
  end
134
186
  end
@@ -17,6 +17,9 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
17
17
  "tool_use" => :tool_calls,
18
18
  "guardrail_intervened" => :content_filter,
19
19
  "content_filtered" => :content_filter,
20
+ "malformed_model_output" => :malformed_output,
21
+ "malformed_tool_use" => :malformed_output,
22
+ "model_context_window_exceeded" => :context_window,
20
23
  }.freeze #: Hash[String, Symbol]
21
24
 
22
25
  # Returns the skill adapter for the Bedrock model — XML for Anthropic models
@@ -43,6 +46,12 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
43
46
  depends_on "aws-sdk-bedrockruntime"
44
47
  end
45
48
 
49
+ #--
50
+ #: (Riffer::Messages::FilePart) -> Symbol
51
+ def file_delivery(file)
52
+ file.url&.start_with?("s3://") ? :url : :bytes
53
+ end
54
+
46
55
  private
47
56
 
48
57
  #--
@@ -413,13 +422,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
413
422
  def convert_file_part_to_bedrock_format(file)
414
423
  format = bedrock_format(file.media_type)
415
424
 
416
- source = if file.data
417
- { bytes: Base64.decode64(file.data) }
418
- elsif file.url&.start_with?("s3://")
419
- { s3_location: { uri: file.url } }
420
- else
421
- raise Riffer::ArgumentError, "Amazon Bedrock only supports S3 URI or base64 data file sources"
422
- end
425
+ source = file.data_bytes ? { bytes: file.data_bytes } : { s3_location: { uri: file.url } }
423
426
 
424
427
  if file.image?
425
428
  { image: { format: format, source: source } }
@@ -12,6 +12,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
12
12
  "max_tokens" => :length,
13
13
  "tool_use" => :tool_calls,
14
14
  "refusal" => :content_filter,
15
+ "model_context_window_exceeded" => :context_window,
16
+ # A paused server-tool turn resumes only by re-sending the response; the
17
+ # agent loop does not do that, so it has no normalized equivalent.
18
+ "pause_turn" => :other,
15
19
  }.freeze #: Hash[String, Symbol]
16
20
 
17
21
  # Returns the XML skill adapter for Anthropic/Claude.
@@ -48,6 +48,7 @@ class Riffer::Providers::Base
48
48
  @current_model = model
49
49
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
50
50
  validate_normalized_messages!(messages)
51
+ Riffer::Files::Resolver.new(provider: self).resolve!(messages)
51
52
  messages = merge_consecutive_messages(messages)
52
53
  params = build_request_params(messages, model, options)
53
54
 
@@ -84,6 +85,7 @@ class Riffer::Providers::Base
84
85
  @current_model = model
85
86
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
86
87
  validate_normalized_messages!(messages)
88
+ Riffer::Files::Resolver.new(provider: self).resolve!(messages)
87
89
  messages = merge_consecutive_messages(messages)
88
90
  params = build_request_params(messages, model, options)
89
91
 
@@ -102,6 +104,12 @@ class Riffer::Providers::Base
102
104
  end
103
105
  end
104
106
 
107
+ #--
108
+ #: (Riffer::Messages::FilePart) -> Symbol
109
+ def file_delivery(_file)
110
+ :url
111
+ end
112
+
105
113
  private
106
114
 
107
115
  #: (String) -> true
@@ -5,7 +5,7 @@
5
5
  # wire value. +reason+ carries the same meaning for every provider.
6
6
  class Riffer::Providers::FinishReason
7
7
  # The normalized vocabulary every provider maps into.
8
- VALUES = %i[stop length tool_calls content_filter error other].freeze #: Array[Symbol]
8
+ VALUES = %i[stop length tool_calls content_filter context_window malformed_output error other].freeze #: Array[Symbol]
9
9
 
10
10
  # The normalized reason.
11
11
  attr_reader :reason #: Symbol
@@ -17,7 +17,16 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
17
17
  "PROHIBITED_CONTENT" => :content_filter,
18
18
  "SPII" => :content_filter,
19
19
  "IMAGE_SAFETY" => :content_filter,
20
- "MALFORMED_FUNCTION_CALL" => :error,
20
+ "IMAGE_PROHIBITED_CONTENT" => :content_filter,
21
+ "IMAGE_RECITATION" => :content_filter,
22
+ "LANGUAGE" => :content_filter,
23
+ "MALFORMED_FUNCTION_CALL" => :malformed_output,
24
+ "UNEXPECTED_TOOL_CALL" => :malformed_output,
25
+ "NO_IMAGE" => :error,
26
+ "TOO_MANY_TOOL_CALLS" => :other,
27
+ "OTHER" => :other,
28
+ "IMAGE_OTHER" => :other,
29
+ "FINISH_REASON_UNSPECIFIED" => :other,
21
30
  }.freeze #: Hash[String, Symbol]
22
31
 
23
32
  # The GenAI semconv well-known provider name.
@@ -27,6 +36,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
27
36
  "gcp.gemini"
28
37
  end
29
38
 
39
+ #--
40
+ #: (Riffer::Messages::FilePart) -> Symbol
41
+ def file_delivery(_file)
42
+ :base64
43
+ end
44
+
30
45
  private
31
46
 
32
47
  #--
@@ -274,11 +289,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
274
289
  #--
275
290
  #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
276
291
  def convert_file_part_to_gemini_format(file)
277
- if file.url?
278
- raise Riffer::ArgumentError,
279
- "Gemini provider does not support URL-based file references. Provide base64-encoded data instead."
280
- end
281
-
282
292
  { inlineData: { mimeType: file.media_type, data: file.data } }
283
293
  end
284
294
 
@@ -5,6 +5,21 @@
5
5
  class Riffer::Providers::OpenAI < Riffer::Providers::Base
6
6
  WEB_SEARCH_TOOL_TYPE = "web_search_preview" #: String
7
7
 
8
+ # The Responses API has no finish_reason field. The response +status+ is
9
+ # the primary signal; an +incomplete+ status is only meaningful together
10
+ # with <tt>incomplete_details.reason</tt>, so that branch nests one level.
11
+ FINISH_REASONS = {
12
+ "completed" => :stop,
13
+ "incomplete" => {
14
+ "max_output_tokens" => :length,
15
+ "content_filter" => :content_filter,
16
+ },
17
+ "failed" => :error,
18
+ "cancelled" => :other,
19
+ "in_progress" => :other,
20
+ "queued" => :other,
21
+ }.freeze #: Hash[String, Symbol | Hash[String, Symbol]]
22
+
8
23
  # The GenAI semconv well-known provider name.
9
24
  #--
10
25
  #: () -> String
@@ -19,6 +34,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
19
34
  depends_on "openai"
20
35
  end
21
36
 
37
+ #--
38
+ #: (Riffer::Messages::FilePart) -> Symbol
39
+ def file_delivery(file)
40
+ file.image? ? :url : :base64
41
+ end
42
+
22
43
  private
23
44
 
24
45
  #--
@@ -127,40 +148,32 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
127
148
  build_finish_reason(response)
128
149
  end
129
150
 
130
- # The Responses API reports no finish_reason field, so one is derived.
131
151
  #--
132
152
  #: (untyped) -> Riffer::Providers::FinishReason?
133
153
  def build_finish_reason(response)
134
154
  typed_response = response #: OpenAI::Models::Responses::Response
135
- status = typed_response.status
155
+ status = typed_response.status&.to_s
136
156
  return nil unless status
137
157
 
138
- case status.to_sym
139
- when :completed
140
- reason = extract_tool_calls(typed_response).empty? ? :stop : :tool_calls
141
- Riffer::Providers::FinishReason.new(reason: reason, raw: "completed")
142
- when :incomplete
143
- incomplete_finish_reason(typed_response)
144
- when :failed
145
- Riffer::Providers::FinishReason.new(reason: :error, raw: "failed")
146
- else
147
- Riffer::Providers::FinishReason.new(reason: :other, raw: status.to_s)
148
- end
158
+ detail = finish_detail(typed_response, status)
159
+ mapping = FINISH_REASONS.fetch(status, :other)
160
+ reason = mapping.is_a?(Hash) ? mapping.fetch(detail.to_s, :other) : mapping
161
+ # A completed response signals tool use only through its output items.
162
+ reason = :tool_calls if reason == :stop && !extract_tool_calls(typed_response).empty?
163
+
164
+ Riffer::Providers::FinishReason.new(reason: reason, raw: detail || status)
149
165
  end
150
166
 
167
+ # The nested field that names the cause behind an ambiguous status.
151
168
  #--
152
- #: (untyped) -> Riffer::Providers::FinishReason
153
- def incomplete_finish_reason(response)
169
+ #: (untyped, String) -> String?
170
+ def finish_detail(response, status)
154
171
  typed_response = response #: OpenAI::Models::Responses::Response
155
- raw = typed_response.incomplete_details&.reason&.to_s
156
172
 
157
- reason = case raw
158
- when "max_output_tokens" then :length
159
- when "content_filter" then :content_filter
160
- else :other
161
- end
162
-
163
- Riffer::Providers::FinishReason.new(reason: reason, raw: raw || "incomplete")
173
+ case status
174
+ when "incomplete" then typed_response.incomplete_details&.reason&.to_s
175
+ when "failed" then typed_response.error&.code&.to_s
176
+ end
164
177
  end
165
178
 
166
179
  #--
@@ -33,6 +33,12 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
33
33
  depends_on "openai"
34
34
  end
35
35
 
36
+ #--
37
+ #: (Riffer::Messages::FilePart) -> Symbol
38
+ def file_delivery(file)
39
+ file.image? ? :url : :base64
40
+ end
41
+
36
42
  private
37
43
 
38
44
  #--
@@ -127,18 +133,30 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
127
133
  #: (untyped) -> Riffer::Providers::FinishReason?
128
134
  def extract_finish_reason(response)
129
135
  typed_response = response #: OpenAI::Models::Chat::ChatCompletion
130
- build_finish_reason(typed_response.choices.first&.finish_reason)
136
+ choice = typed_response.choices.first
137
+ build_finish_reason(choice&.finish_reason, native: native_finish_reason(choice))
131
138
  end
132
139
 
140
+ # +native+ is the upstream model's own finish reason, which OpenRouter
141
+ # reports alongside its normalized one; it wins as +raw+ when present.
133
142
  #--
134
- #: (untyped) -> Riffer::Providers::FinishReason?
135
- def build_finish_reason(finish_reason)
143
+ #: (untyped, ?native: untyped) -> Riffer::Providers::FinishReason?
144
+ def build_finish_reason(finish_reason, native: nil)
136
145
  return nil unless finish_reason
137
146
 
138
- raw = finish_reason.to_s
139
- return nil if raw.empty?
147
+ normalized = finish_reason.to_s
148
+ return nil if normalized.empty?
140
149
 
141
- Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(raw, :other), raw: raw)
150
+ raw = native.to_s.empty? ? normalized : native.to_s
151
+ Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(normalized, :other), raw: raw)
152
+ end
153
+
154
+ # +native_finish_reason+ is outside the OpenAI schema, so it is only
155
+ # reachable through the SDK model's raw data hash.
156
+ #--
157
+ #: (untyped) -> untyped
158
+ def native_finish_reason(choice)
159
+ choice && choice.to_h[:native_finish_reason]
142
160
  end
143
161
 
144
162
  #--
@@ -181,6 +199,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
181
199
  reasoning: +"",
182
200
  tool_calls: {},
183
201
  finish_reason: nil,
202
+ native_finish_reason: nil,
184
203
  } #: Hash[Symbol, untyped]
185
204
 
186
205
  # Use stream_raw (not stream) — the latter yields a higher-level
@@ -205,7 +224,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
205
224
 
206
225
  yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
207
226
  yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
208
- yield_finish_reason(yielder, build_finish_reason(state[:finish_reason]))
227
+ yield_finish_reason(yielder, build_finish_reason(state[:finish_reason], native: state[:native_finish_reason]))
209
228
  end
210
229
 
211
230
  #--
@@ -222,6 +241,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
222
241
  end
223
242
 
224
243
  state[:finish_reason] = choice.finish_reason if choice&.finish_reason
244
+ state[:native_finish_reason] = native_finish_reason(choice) || state[:native_finish_reason]
225
245
 
226
246
  emit_tool_call_done_events(state: state, yielder: yielder) if choice && finish_reason_is_tool_calls?(choice)
227
247