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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +15 -0
- data/Steepfile +1 -0
- data/docs/AGENTS.md +23 -2
- data/docs/CONFIGURATION.md +32 -0
- data/docs/MESSAGES.md +22 -10
- data/docs/OVERVIEW.md +1 -0
- data/docs/TESTING.md +129 -0
- data/docs/TOOLS.md +23 -2
- data/docs/TRACING.md +1 -1
- data/docs/providers/AMAZON_BEDROCK.md +17 -0
- data/docs/providers/CUSTOM_PROVIDERS.md +1 -1
- data/docs/providers/GEMINI.md +2 -3
- data/docs-site/manifest.yml +3 -0
- data/lib/riffer/config.rb +94 -0
- data/lib/riffer/files/downloader.rb +78 -0
- data/lib/riffer/files/resolver.rb +97 -0
- data/lib/riffer/files.rb +5 -0
- data/lib/riffer/helpers/identifier.rb +11 -3
- data/lib/riffer/messages/file_part.rb +61 -9
- data/lib/riffer/providers/amazon_bedrock.rb +10 -7
- data/lib/riffer/providers/anthropic.rb +4 -0
- data/lib/riffer/providers/base.rb +8 -0
- data/lib/riffer/providers/finish_reason.rb +1 -1
- data/lib/riffer/providers/gemini.rb +16 -6
- data/lib/riffer/providers/open_ai.rb +36 -23
- data/lib/riffer/providers/open_router.rb +27 -7
- data/lib/riffer/registrable.rb +105 -22
- data/lib/riffer/testing/minitest.rb +21 -0
- data/lib/riffer/testing/rspec.rb +11 -0
- data/lib/riffer/testing.rb +130 -0
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +31 -0
- data/sig/_private/minitest.rbs +9 -0
- data/sig/_private/riffer/testing/minitest.rbs +6 -0
- data/sig/_private/rspec.rbs +7 -0
- data/sig/_private/zeitwerk.rbs +2 -0
- data/sig/generated/riffer/config.rbs +64 -0
- data/sig/generated/riffer/files/downloader.rbs +16 -0
- data/sig/generated/riffer/files/resolver.rbs +36 -0
- data/sig/generated/riffer/files.rbs +4 -0
- data/sig/generated/riffer/helpers/identifier.rbs +8 -0
- data/sig/generated/riffer/messages/file_part.rbs +38 -6
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -0
- data/sig/generated/riffer/providers/base.rbs +4 -0
- data/sig/generated/riffer/providers/gemini.rbs +4 -0
- data/sig/generated/riffer/providers/open_ai.rbs +12 -3
- data/sig/generated/riffer/providers/open_router.rbs +14 -2
- data/sig/generated/riffer/registrable.rbs +57 -9
- data/sig/generated/riffer/testing/rspec.rbs +2 -0
- data/sig/generated/riffer/testing.rbs +75 -0
- data/sig/generated/riffer.rbs +32 -0
- data/sig/manifest.yaml +1 -0
- data/sig/manual/riffer/testing.rbs +5 -0
- 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
|
data/lib/riffer/files.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
89
|
-
|
|
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 =
|
|
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
|
-
"
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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) ->
|
|
153
|
-
def
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
return nil if
|
|
147
|
+
normalized = finish_reason.to_s
|
|
148
|
+
return nil if normalized.empty?
|
|
140
149
|
|
|
141
|
-
|
|
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
|
|