riffer 0.28.0 → 0.29.1
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/.agents/architecture.md +18 -11
- data/.agents/code-style.md +1 -1
- data/.agents/rbs-inline.md +53 -2
- data/.agents/testing.md +9 -5
- data/.release-please-manifest.json +1 -1
- data/AGENTS.md +17 -10
- data/CHANGELOG.md +26 -0
- data/README.md +17 -18
- data/Steepfile +8 -1
- data/docs/03_AGENTS.md +34 -3
- data/docs/04_AGENT_LIFECYCLE.md +87 -86
- data/docs/05_AGENT_LOOP.md +2 -2
- data/docs/06_TOOLS.md +9 -4
- data/docs/07_TOOL_ADVANCED.md +17 -17
- data/docs/08_MESSAGES.md +25 -32
- data/docs/09_STREAM_EVENTS.md +1 -1
- data/docs/10_CONFIGURATION.md +7 -18
- data/docs/providers/01_PROVIDERS.md +6 -0
- data/docs/providers/06_MOCK_PROVIDER.md +2 -1
- data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
- data/docs/providers/08_GEMINI.md +2 -2
- data/docs/providers/09_OPENROUTER.md +242 -0
- data/lib/riffer/agent/config.rb +173 -0
- data/lib/riffer/agent/context.rb +127 -0
- data/lib/riffer/agent/response.rb +2 -0
- data/lib/riffer/agent/run.rb +308 -0
- data/lib/riffer/agent/session/repair.rb +112 -0
- data/lib/riffer/agent/session.rb +270 -0
- data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
- data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
- data/lib/riffer/agent.rb +236 -923
- data/lib/riffer/config.rb +14 -7
- data/lib/riffer/evals/evaluator.rb +18 -3
- data/lib/riffer/evals/judge.rb +7 -2
- data/lib/riffer/evals/run_result.rb +2 -1
- data/lib/riffer/evals/scenario_result.rb +2 -1
- data/lib/riffer/guardrails/runner.rb +3 -2
- data/lib/riffer/helpers/call_or_value.rb +16 -0
- data/lib/riffer/helpers.rb +0 -1
- data/lib/riffer/mcp/authenticated_tool.rb +4 -0
- data/lib/riffer/mcp/client.rb +3 -1
- data/lib/riffer/mcp/registration.rb +6 -3
- data/lib/riffer/mcp/registry.rb +6 -1
- data/lib/riffer/mcp/tool_factory.rb +5 -0
- data/lib/riffer/messages/assistant.rb +9 -3
- data/lib/riffer/messages/base.rb +22 -0
- data/lib/riffer/messages/converter.rb +6 -6
- data/lib/riffer/{file_part.rb → messages/file_part.rb} +7 -5
- data/lib/riffer/messages/tool.rb +1 -1
- data/lib/riffer/messages/user.rb +4 -4
- data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
- data/lib/riffer/{param.rb → params/param.rb} +6 -6
- data/lib/riffer/params.rb +27 -21
- data/lib/riffer/providers/amazon_bedrock.rb +37 -31
- data/lib/riffer/providers/anthropic.rb +39 -36
- data/lib/riffer/providers/base.rb +12 -9
- data/lib/riffer/providers/gemini.rb +19 -12
- data/lib/riffer/providers/mock.rb +45 -13
- data/lib/riffer/providers/open_ai.rb +34 -29
- data/lib/riffer/providers/open_router.rb +325 -0
- data/lib/riffer/providers/repository.rb +1 -0
- data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
- data/lib/riffer/providers.rb +1 -0
- data/lib/riffer/runner/fibers.rb +6 -3
- data/lib/riffer/runner/sequential.rb +1 -1
- data/lib/riffer/runner/threaded.rb +3 -1
- data/lib/riffer/runner.rb +1 -1
- data/lib/riffer/skills/activate_tool.rb +4 -3
- data/lib/riffer/skills/config.rb +6 -1
- data/lib/riffer/skills/context.rb +6 -3
- data/lib/riffer/skills/filesystem_backend.rb +10 -5
- data/lib/riffer/skills/markdown_adapter.rb +1 -1
- data/lib/riffer/skills/xml_adapter.rb +1 -1
- data/lib/riffer/stream_events/interrupt.rb +1 -1
- data/lib/riffer/stream_events/token_usage_done.rb +2 -2
- data/lib/riffer/stream_events/web_search_status.rb +1 -1
- data/lib/riffer/tool.rb +3 -3
- data/lib/riffer/tools/response.rb +2 -0
- data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
- data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
- data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
- data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +11 -9
- data/lib/riffer/{toolable.rb → tools/toolable.rb} +19 -9
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +4 -1
- data/sig/_private/anthropic.rbs +16 -0
- data/sig/_private/async.rbs +24 -0
- data/sig/_private/aws-sdk-core/seahorse_request_context.rbs +7 -0
- data/sig/_private/aws-sdk-core/static_token_provider.rbs +5 -0
- data/sig/_private/mcp.rbs +22 -0
- data/sig/_private/openai.rbs +29 -0
- data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -0
- data/sig/_private/riffer/providers/anthropic.rbs +4 -0
- data/sig/_private/riffer/providers/open_ai.rbs +4 -0
- data/sig/_private/riffer/providers/open_router.rbs +4 -0
- data/sig/_private/zeitwerk.rbs +12 -0
- data/sig/generated/riffer/agent/config.rbs +119 -0
- data/sig/generated/riffer/agent/context.rbs +93 -0
- data/sig/generated/riffer/agent/response.rbs +2 -0
- data/sig/generated/riffer/agent/run.rbs +144 -0
- data/sig/generated/riffer/agent/session/repair.rbs +51 -0
- data/sig/generated/riffer/agent/session.rbs +147 -0
- data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
- data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
- data/sig/generated/riffer/agent.rbs +145 -342
- data/sig/generated/riffer/config.rbs +17 -5
- data/sig/generated/riffer/evals/evaluator.rbs +8 -0
- data/sig/generated/riffer/evals/judge.rbs +10 -2
- data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
- data/sig/generated/riffer/helpers.rbs +0 -1
- data/sig/generated/riffer/mcp/client.rbs +2 -0
- data/sig/generated/riffer/mcp/registration.rbs +6 -0
- data/sig/generated/riffer/mcp/registry.rbs +4 -0
- data/sig/generated/riffer/messages/assistant.rbs +7 -3
- data/sig/generated/riffer/messages/base.rbs +18 -0
- data/sig/generated/riffer/messages/converter.rbs +4 -4
- data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +7 -5
- data/sig/generated/riffer/messages/user.rbs +4 -4
- data/sig/generated/riffer/params/boolean.rbs +10 -0
- data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
- data/sig/generated/riffer/params.rbs +15 -15
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
- data/sig/generated/riffer/providers/anthropic.rbs +12 -12
- data/sig/generated/riffer/providers/base.rbs +12 -10
- data/sig/generated/riffer/providers/gemini.rbs +10 -4
- data/sig/generated/riffer/providers/mock.rbs +31 -5
- data/sig/generated/riffer/providers/open_ai.rbs +10 -10
- data/sig/generated/riffer/providers/open_router.rbs +85 -0
- data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
- data/sig/generated/riffer/providers.rbs +1 -0
- data/sig/generated/riffer/runner/fibers.rbs +4 -2
- data/sig/generated/riffer/runner/sequential.rbs +2 -2
- data/sig/generated/riffer/runner/threaded.rbs +4 -2
- data/sig/generated/riffer/runner.rbs +2 -2
- data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
- data/sig/generated/riffer/skills/config.rbs +9 -1
- data/sig/generated/riffer/skills/context.rbs +6 -2
- data/sig/generated/riffer/skills/filesystem_backend.rbs +4 -0
- data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
- data/sig/generated/riffer/tool.rbs +5 -5
- data/sig/generated/riffer/tools/response.rbs +2 -0
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
- data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +14 -12
- data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +18 -6
- data/sig/generated/riffer.rbs +2 -0
- data/sig/manifest.yaml +3 -0
- data/sig/manual/riffer/agent/run.rbs +5 -0
- data/sig/manual/riffer/helpers/call_or_value.rbs +5 -0
- data/sig/manual/riffer/tools/toolable.rbs +6 -0
- metadata +59 -33
- data/lib/riffer/core.rb +0 -28
- data/lib/riffer/helpers/validations.rb +0 -18
- data/sig/generated/riffer/boolean.rbs +0 -10
- data/sig/generated/riffer/core.rbs +0 -19
- data/sig/generated/riffer/helpers/validations.rbs +0 -12
|
@@ -8,6 +8,10 @@ require "uri"
|
|
|
8
8
|
|
|
9
9
|
# Google Gemini provider for Gemini models via the Gemini REST API.
|
|
10
10
|
class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
11
|
+
# @rbs @api_key: String?
|
|
12
|
+
# @rbs @open_timeout: Integer
|
|
13
|
+
# @rbs @read_timeout: Integer
|
|
14
|
+
|
|
11
15
|
BASE_URI = URI("https://generativelanguage.googleapis.com") #: URI::Generic
|
|
12
16
|
VALID_MODEL_PATTERN = /\A[a-zA-Z0-9._-]+\z/ #: Regexp
|
|
13
17
|
DEFAULT_OPEN_TIMEOUT = 10 #: Integer
|
|
@@ -36,7 +40,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
36
40
|
params = {
|
|
37
41
|
model: model,
|
|
38
42
|
contents: partitioned[:contents]
|
|
39
|
-
}
|
|
43
|
+
} #: Hash[Symbol, untyped]
|
|
40
44
|
|
|
41
45
|
params[:systemInstruction] = partitioned[:system_instruction] if partitioned[:system_instruction]
|
|
42
46
|
|
|
@@ -96,12 +100,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
#--
|
|
99
|
-
#: (Hash[Symbol, untyped]) -> Riffer::TokenUsage?
|
|
103
|
+
#: (Hash[Symbol, untyped]) -> Riffer::Providers::TokenUsage?
|
|
100
104
|
def extract_token_usage(response)
|
|
101
105
|
usage = response[:usageMetadata]
|
|
102
106
|
return nil unless usage
|
|
103
107
|
|
|
104
|
-
Riffer::TokenUsage.new(
|
|
108
|
+
Riffer::Providers::TokenUsage.new(
|
|
105
109
|
input_tokens: usage[:promptTokenCount] || 0,
|
|
106
110
|
output_tokens: usage[:candidatesTokenCount] || 0
|
|
107
111
|
)
|
|
@@ -114,6 +118,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
114
118
|
body = params.except(:model)
|
|
115
119
|
|
|
116
120
|
uri = URI("#{BASE_URI}/#{api_path(model, "streamGenerateContent")}?alt=sse")
|
|
121
|
+
host = uri.hostname #: String
|
|
117
122
|
request = Net::HTTP::Post.new(uri)
|
|
118
123
|
request["Content-Type"] = "application/json"
|
|
119
124
|
request["x-goog-api-key"] = @api_key
|
|
@@ -126,7 +131,8 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
126
131
|
buffer << chunk
|
|
127
132
|
|
|
128
133
|
while (match = buffer.match(/\r?\n\r?\n/))
|
|
129
|
-
|
|
134
|
+
match_end = match.end(0) #: Integer
|
|
135
|
+
frame = buffer.slice!(0, match_end).to_s.strip
|
|
130
136
|
next unless frame.start_with?("data: ")
|
|
131
137
|
|
|
132
138
|
json_str = frame.delete_prefix("data: ").strip
|
|
@@ -155,7 +161,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
155
161
|
usage = parsed[:usageMetadata]
|
|
156
162
|
if usage && usage[:candidatesTokenCount]
|
|
157
163
|
yielder << Riffer::StreamEvents::TokenUsageDone.new(
|
|
158
|
-
token_usage: Riffer::TokenUsage.new(
|
|
164
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
159
165
|
input_tokens: usage[:promptTokenCount] || 0,
|
|
160
166
|
output_tokens: usage[:candidatesTokenCount] || 0
|
|
161
167
|
)
|
|
@@ -164,7 +170,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
164
170
|
end
|
|
165
171
|
end
|
|
166
172
|
|
|
167
|
-
Net::HTTP.start(
|
|
173
|
+
Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
|
|
168
174
|
http.request(request) do |response|
|
|
169
175
|
handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
|
|
170
176
|
|
|
@@ -182,8 +188,8 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
182
188
|
#--
|
|
183
189
|
#: (Array[Riffer::Messages::Base]) -> Hash[Symbol, untyped]
|
|
184
190
|
def partition_messages(messages)
|
|
185
|
-
system_parts = []
|
|
186
|
-
contents = []
|
|
191
|
+
system_parts = [] #: Array[Hash[Symbol, untyped]]
|
|
192
|
+
contents = [] #: Array[Hash[Symbol, untyped]]
|
|
187
193
|
|
|
188
194
|
messages.each do |message|
|
|
189
195
|
case message
|
|
@@ -212,7 +218,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
212
218
|
end
|
|
213
219
|
end
|
|
214
220
|
|
|
215
|
-
result = {contents: contents}
|
|
221
|
+
result = {contents: contents} #: Hash[Symbol, untyped]
|
|
216
222
|
result[:system_instruction] = {parts: system_parts} unless system_parts.empty?
|
|
217
223
|
result
|
|
218
224
|
end
|
|
@@ -220,7 +226,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
220
226
|
#--
|
|
221
227
|
#: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
|
|
222
228
|
def convert_assistant_to_gemini_format(message)
|
|
223
|
-
parts = []
|
|
229
|
+
parts = [] #: Array[Hash[Symbol, untyped]]
|
|
224
230
|
parts << {text: message.content} if message.content && !message.content.empty?
|
|
225
231
|
|
|
226
232
|
message.tool_calls.each do |tc|
|
|
@@ -236,7 +242,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
236
242
|
end
|
|
237
243
|
|
|
238
244
|
#--
|
|
239
|
-
#: (Riffer::FilePart) -> Hash[Symbol, untyped]
|
|
245
|
+
#: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
|
|
240
246
|
def convert_file_part_to_gemini_format(file)
|
|
241
247
|
if file.url?
|
|
242
248
|
raise Riffer::ArgumentError,
|
|
@@ -268,11 +274,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
268
274
|
#: (String, Hash[Symbol, untyped]) -> Net::HTTPResponse
|
|
269
275
|
def post_request(path, body)
|
|
270
276
|
uri = URI("#{BASE_URI}/#{path}")
|
|
277
|
+
host = uri.hostname #: String
|
|
271
278
|
request = Net::HTTP::Post.new(uri)
|
|
272
279
|
request["Content-Type"] = "application/json"
|
|
273
280
|
request["x-goog-api-key"] = @api_key
|
|
274
281
|
request.body = body.to_json
|
|
275
|
-
Net::HTTP.start(
|
|
282
|
+
Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) { |http| http.request(request) }
|
|
276
283
|
end
|
|
277
284
|
|
|
278
285
|
#--
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
#
|
|
6
6
|
# No external gems required.
|
|
7
7
|
class Riffer::Providers::Mock < Riffer::Providers::Base
|
|
8
|
+
# @rbs @responses: Array[Hash[Symbol, untyped]]
|
|
9
|
+
# @rbs @current_index: Integer
|
|
10
|
+
# @rbs @stubbed_responses: Array[Hash[Symbol, untyped]]
|
|
11
|
+
|
|
8
12
|
# Returns the preferred skill adapter for the given mock model.
|
|
9
13
|
#
|
|
10
14
|
# Mock is used to stand in for any real provider in tests, so the model
|
|
@@ -25,10 +29,21 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
|
|
|
25
29
|
|
|
26
30
|
# Initializes the mock provider.
|
|
27
31
|
#
|
|
32
|
+
# +responses:+ accepts an array of response hashes in the same shape
|
|
33
|
+
# +#stub_response+ takes — raw +tool_calls:+ hashes are normalised to
|
|
34
|
+
# +Riffer::Messages::Assistant::ToolCall+ instances. This is the canonical
|
|
35
|
+
# way to pre-configure canned LLM responses on an agent via
|
|
36
|
+
# +provider_options responses: [...]+.
|
|
37
|
+
#
|
|
38
|
+
# Riffer::Providers::Mock.new(responses: [
|
|
39
|
+
# {content: "", tool_calls: [{name: "tool_a", arguments: "{}"}]},
|
|
40
|
+
# {content: "Final answer"}
|
|
41
|
+
# ])
|
|
42
|
+
#
|
|
28
43
|
#--
|
|
29
44
|
#: (**untyped) -> void
|
|
30
45
|
def initialize(**options)
|
|
31
|
-
@responses = options[:responses] || []
|
|
46
|
+
@responses = (options[:responses] || []).map { |r| normalize_response(r) }
|
|
32
47
|
@current_index = 0
|
|
33
48
|
@calls = []
|
|
34
49
|
@stubbed_responses = []
|
|
@@ -40,19 +55,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
|
|
|
40
55
|
#
|
|
41
56
|
# provider.stub_response("Hello")
|
|
42
57
|
# provider.stub_response("", tool_calls: [{name: "my_tool", arguments: '{"key":"value"}'}])
|
|
43
|
-
# provider.stub_response("Final response", token_usage: Riffer::TokenUsage.new(input_tokens: 10, output_tokens: 5))
|
|
58
|
+
# provider.stub_response("Final response", token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
|
|
44
59
|
#
|
|
45
60
|
#--
|
|
46
|
-
#: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::TokenUsage?) -> void
|
|
61
|
+
#: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?) -> void
|
|
47
62
|
def stub_response(content, tool_calls: [], token_usage: nil)
|
|
48
|
-
|
|
49
|
-
Riffer::Messages::Assistant::ToolCall.new(
|
|
50
|
-
call_id: tc[:call_id] || tc[:id] || "mock_call_#{idx}",
|
|
51
|
-
name: tc[:name],
|
|
52
|
-
arguments: tc[:arguments].is_a?(String) ? tc[:arguments] : tc[:arguments].to_json
|
|
53
|
-
)
|
|
54
|
-
end
|
|
55
|
-
@stubbed_responses << {role: "assistant", content: content, tool_calls: formatted_tool_calls, token_usage: token_usage}
|
|
63
|
+
@stubbed_responses << normalize_response(content: content, tool_calls: tool_calls, token_usage: token_usage)
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
# Clears all stubbed responses.
|
|
@@ -65,6 +73,30 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
|
|
|
65
73
|
|
|
66
74
|
private
|
|
67
75
|
|
|
76
|
+
# Normalises a response hash into Mock's internal format. Accepts the
|
|
77
|
+
# +#stub_response+ kwargs shape (+content:+, +tool_calls:+, +token_usage:+)
|
|
78
|
+
# or a pre-built hash with already-converted ToolCall instances. Raw
|
|
79
|
+
# +tool_calls:+ hashes are wrapped in +Riffer::Messages::Assistant::ToolCall+.
|
|
80
|
+
#
|
|
81
|
+
#--
|
|
82
|
+
#: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
83
|
+
def normalize_response(response)
|
|
84
|
+
formatted_tool_calls = (response[:tool_calls] || []).map.with_index do |tc, idx|
|
|
85
|
+
next tc if tc.is_a?(Riffer::Messages::Assistant::ToolCall)
|
|
86
|
+
Riffer::Messages::Assistant::ToolCall.new(
|
|
87
|
+
call_id: tc[:call_id] || tc[:id] || "mock_call_#{idx}",
|
|
88
|
+
name: tc[:name],
|
|
89
|
+
arguments: tc[:arguments].is_a?(String) ? tc[:arguments] : tc[:arguments].to_json
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
{
|
|
93
|
+
role: response[:role] || "assistant",
|
|
94
|
+
content: response[:content] || "",
|
|
95
|
+
tool_calls: formatted_tool_calls,
|
|
96
|
+
token_usage: response[:token_usage]
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
68
100
|
#--
|
|
69
101
|
#: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
70
102
|
def build_request_params(messages, model, options)
|
|
@@ -82,7 +114,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
|
|
|
82
114
|
end
|
|
83
115
|
|
|
84
116
|
#--
|
|
85
|
-
#: (untyped) -> Riffer::TokenUsage?
|
|
117
|
+
#: (untyped) -> Riffer::Providers::TokenUsage?
|
|
86
118
|
def extract_token_usage(response)
|
|
87
119
|
response[:token_usage]
|
|
88
120
|
end
|
|
@@ -146,7 +178,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
|
|
|
146
178
|
def next_response
|
|
147
179
|
if @stubbed_responses.any?
|
|
148
180
|
@stubbed_responses.shift
|
|
149
|
-
elsif @responses
|
|
181
|
+
elsif @current_index < @responses.size
|
|
150
182
|
response = @responses[@current_index]
|
|
151
183
|
@current_index += 1
|
|
152
184
|
response
|
|
@@ -36,9 +36,9 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
36
36
|
summary: "auto"
|
|
37
37
|
},
|
|
38
38
|
**options.except(:reasoning, :tools, :structured_output, :web_search)
|
|
39
|
-
}
|
|
39
|
+
} #: Hash[Symbol, untyped]
|
|
40
40
|
|
|
41
|
-
openai_tools = []
|
|
41
|
+
openai_tools = [] #: Array[Hash[Symbol, untyped]]
|
|
42
42
|
openai_tools.concat(tools.map { |t| convert_tool_to_openai_format(t) }) if tools && !tools.empty?
|
|
43
43
|
|
|
44
44
|
if web_search
|
|
@@ -66,51 +66,54 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
#--
|
|
69
|
-
#: (Hash[Symbol, untyped]) ->
|
|
69
|
+
#: (Hash[Symbol, untyped]) -> untyped
|
|
70
70
|
def execute_generate(params)
|
|
71
71
|
@client.responses.create(params)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
#--
|
|
75
|
-
#: (
|
|
75
|
+
#: (untyped) -> Riffer::Providers::TokenUsage?
|
|
76
76
|
def extract_token_usage(response)
|
|
77
|
-
|
|
77
|
+
typed_response = response #: OpenAI::Models::Responses::Response
|
|
78
|
+
usage = typed_response.usage
|
|
78
79
|
return nil unless usage
|
|
79
80
|
|
|
80
|
-
Riffer::TokenUsage.new(
|
|
81
|
+
Riffer::Providers::TokenUsage.new(
|
|
81
82
|
input_tokens: usage.input_tokens,
|
|
82
83
|
output_tokens: usage.output_tokens
|
|
83
84
|
)
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
#--
|
|
87
|
-
#: (
|
|
88
|
+
#: (untyped) -> String
|
|
88
89
|
def extract_content(response)
|
|
90
|
+
typed_response = response #: OpenAI::Models::Responses::Response
|
|
89
91
|
text_content = ""
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
typed_response.output.each do |item|
|
|
94
|
+
next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
|
|
95
|
+
|
|
96
|
+
text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
|
|
97
|
+
text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
text_content
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
#--
|
|
102
|
-
#: (
|
|
104
|
+
#: (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
|
|
103
105
|
def extract_tool_calls(response)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
|
|
106
|
+
typed_response = response #: OpenAI::Models::Responses::Response
|
|
107
|
+
tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
|
|
108
|
+
|
|
109
|
+
typed_response.output.each do |item|
|
|
110
|
+
next unless item.is_a?(::OpenAI::Models::Responses::ResponseFunctionToolCall)
|
|
111
|
+
|
|
112
|
+
tool_calls << Riffer::Messages::Assistant::ToolCall.new(
|
|
113
|
+
call_id: item.call_id,
|
|
114
|
+
name: decode_tool_name(item.name, tools: @current_tools),
|
|
115
|
+
arguments: item.arguments
|
|
116
|
+
)
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
tool_calls
|
|
@@ -121,7 +124,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
121
124
|
def execute_stream(params, yielder)
|
|
122
125
|
current_state = {
|
|
123
126
|
tool_info: {}
|
|
124
|
-
}
|
|
127
|
+
} #: Hash[Symbol, untyped]
|
|
125
128
|
|
|
126
129
|
stream = @client.responses.stream(params)
|
|
127
130
|
begin
|
|
@@ -224,7 +227,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
224
227
|
return unless usage
|
|
225
228
|
|
|
226
229
|
yielder << Riffer::StreamEvents::TokenUsageDone.new(
|
|
227
|
-
token_usage: Riffer::TokenUsage.new(
|
|
230
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
228
231
|
input_tokens: usage.input_tokens,
|
|
229
232
|
output_tokens: usage.output_tokens
|
|
230
233
|
)
|
|
@@ -242,11 +245,11 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
242
245
|
def handle_output_item_done_web_search(event, yielder:)
|
|
243
246
|
action = event.item.action
|
|
244
247
|
case action
|
|
245
|
-
when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
|
|
248
|
+
when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
|
|
246
249
|
# OpenPage carries a url but no query or sources, so it doesn't fit
|
|
247
250
|
# WebSearchDone — emit as a status notification instead.
|
|
248
251
|
yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
|
|
249
|
-
when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
|
|
252
|
+
when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
|
|
250
253
|
sources = (action.sources || []).map { |s| {title: nil, url: s.url} }
|
|
251
254
|
yielder << Riffer::StreamEvents::WebSearchDone.new(action.query, sources: sources)
|
|
252
255
|
end
|
|
@@ -275,6 +278,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
275
278
|
call_id: message.tool_call_id,
|
|
276
279
|
output: message.content
|
|
277
280
|
}
|
|
281
|
+
else
|
|
282
|
+
raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
|
|
278
283
|
end
|
|
279
284
|
end
|
|
280
285
|
end
|
|
@@ -285,7 +290,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
285
290
|
if message.tool_calls.empty?
|
|
286
291
|
{role: "assistant", content: message.content}
|
|
287
292
|
else
|
|
288
|
-
items = []
|
|
293
|
+
items = [] #: Array[Hash[Symbol, untyped]]
|
|
289
294
|
items << {type: "message", role: "assistant", content: message.content} if message.content && !message.content.empty?
|
|
290
295
|
message.tool_calls.each do |tc|
|
|
291
296
|
items << {
|
|
@@ -300,7 +305,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
300
305
|
end
|
|
301
306
|
|
|
302
307
|
#--
|
|
303
|
-
#: (Riffer::FilePart) -> Hash[Symbol, untyped]
|
|
308
|
+
#: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
|
|
304
309
|
def convert_file_part_to_openai_format(file)
|
|
305
310
|
if file.image?
|
|
306
311
|
image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# OpenRouter provider for the OpenRouter unified gateway (https://openrouter.ai).
|
|
7
|
+
#
|
|
8
|
+
# Requires the +openai+ gem to be installed. OpenRouter exposes an
|
|
9
|
+
# OpenAI-compatible Chat Completions endpoint, so this provider reuses
|
|
10
|
+
# the OpenAI Ruby SDK with a +base_url+ override.
|
|
11
|
+
#
|
|
12
|
+
# The +api_key+ falls back to <tt>Riffer.config.openrouter.api_key</tt>
|
|
13
|
+
# and then to +OPENROUTER_API_KEY+.
|
|
14
|
+
class Riffer::Providers::OpenRouter < Riffer::Providers::Base
|
|
15
|
+
BASE_URL = "https://openrouter.ai/api/v1" #: String
|
|
16
|
+
|
|
17
|
+
# Initializes the OpenRouter provider.
|
|
18
|
+
#
|
|
19
|
+
#--
|
|
20
|
+
#: (?api_key: String?, **untyped) -> void
|
|
21
|
+
def initialize(api_key: nil, **options)
|
|
22
|
+
depends_on "openai"
|
|
23
|
+
|
|
24
|
+
api_key ||= Riffer.config.openrouter.api_key || ENV["OPENROUTER_API_KEY"]
|
|
25
|
+
|
|
26
|
+
@client = ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL, **options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
#--
|
|
32
|
+
#: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
33
|
+
def build_request_params(messages, model, options)
|
|
34
|
+
reasoning = options[:reasoning]
|
|
35
|
+
tools = options[:tools]
|
|
36
|
+
structured_output = options[:structured_output]
|
|
37
|
+
|
|
38
|
+
params = {
|
|
39
|
+
model: model,
|
|
40
|
+
messages: convert_messages_to_chat_completions_format(messages),
|
|
41
|
+
**options.except(:reasoning, :tools, :structured_output)
|
|
42
|
+
} #: Hash[Symbol, untyped]
|
|
43
|
+
|
|
44
|
+
if reasoning
|
|
45
|
+
params[:reasoning] = reasoning.is_a?(String) ? {effort: reasoning} : reasoning
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if tools && !tools.empty?
|
|
49
|
+
params[:tools] = tools.map { |t| convert_tool_to_chat_completions_format(t) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if structured_output
|
|
53
|
+
params[:response_format] = {
|
|
54
|
+
type: "json_schema",
|
|
55
|
+
json_schema: {
|
|
56
|
+
name: "response",
|
|
57
|
+
schema: structured_output.json_schema(strict: true),
|
|
58
|
+
strict: true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
params.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
#--
|
|
67
|
+
#: (Hash[Symbol, untyped]) -> untyped
|
|
68
|
+
def execute_generate(params)
|
|
69
|
+
@client.chat.completions.create(**params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#--
|
|
73
|
+
#: (untyped) -> Riffer::Providers::TokenUsage?
|
|
74
|
+
def extract_token_usage(response)
|
|
75
|
+
typed_response = response #: OpenAI::Models::Chat::ChatCompletion
|
|
76
|
+
usage = typed_response.usage
|
|
77
|
+
return nil unless usage
|
|
78
|
+
|
|
79
|
+
Riffer::Providers::TokenUsage.new(
|
|
80
|
+
input_tokens: usage.prompt_tokens,
|
|
81
|
+
output_tokens: usage.completion_tokens
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#--
|
|
86
|
+
#: (untyped) -> String
|
|
87
|
+
def extract_content(response)
|
|
88
|
+
typed_response = response #: OpenAI::Models::Chat::ChatCompletion
|
|
89
|
+
typed_response.choices.first&.message&.content || ""
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#--
|
|
93
|
+
#: (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
|
|
94
|
+
def extract_tool_calls(response)
|
|
95
|
+
typed_response = response #: OpenAI::Models::Chat::ChatCompletion
|
|
96
|
+
message = typed_response.choices.first&.message
|
|
97
|
+
return [] unless message
|
|
98
|
+
|
|
99
|
+
tool_calls = message.tool_calls
|
|
100
|
+
return [] if tool_calls.nil? || tool_calls.empty?
|
|
101
|
+
|
|
102
|
+
tool_calls.filter_map do |tc|
|
|
103
|
+
next unless tc.is_a?(::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall)
|
|
104
|
+
|
|
105
|
+
Riffer::Messages::Assistant::ToolCall.new(
|
|
106
|
+
call_id: tc.id,
|
|
107
|
+
name: decode_tool_name(tc.function.name, tools: @current_tools),
|
|
108
|
+
arguments: tc.function.arguments
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#--
|
|
114
|
+
#: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
|
|
115
|
+
def execute_stream(params, yielder)
|
|
116
|
+
# OpenRouter omits usage from streams unless explicitly opted in.
|
|
117
|
+
stream_options = (params[:stream_options] || {}).merge(include_usage: true)
|
|
118
|
+
stream_params = params.merge(stream_options: stream_options)
|
|
119
|
+
|
|
120
|
+
state = {
|
|
121
|
+
text: +"",
|
|
122
|
+
reasoning: +"",
|
|
123
|
+
tool_calls: {}
|
|
124
|
+
} #: Hash[Symbol, untyped]
|
|
125
|
+
|
|
126
|
+
# Use stream_raw (not stream) — the latter yields a higher-level
|
|
127
|
+
# ChatChunkEvent helper that aggregates content/tool calls into typed
|
|
128
|
+
# events. We want raw ChatCompletionChunk objects with
|
|
129
|
+
# +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
|
|
130
|
+
# ourselves.
|
|
131
|
+
stream = @client.chat.completions.stream_raw(**stream_params)
|
|
132
|
+
begin
|
|
133
|
+
stream.each do |chunk|
|
|
134
|
+
handle_stream_chunk(chunk, state: state, yielder: yielder)
|
|
135
|
+
end
|
|
136
|
+
ensure
|
|
137
|
+
# The OpenAI SDK does not auto-close the SSE socket on iteration
|
|
138
|
+
# interrupt, so close explicitly. Idempotent and a no-op after EOF.
|
|
139
|
+
stream.close
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Chat Completions has no per-tool terminal event, so flush any leftover
|
|
143
|
+
# tool calls here in case finish_reason is missing or not "tool_calls".
|
|
144
|
+
emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?
|
|
145
|
+
|
|
146
|
+
yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
|
|
147
|
+
yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
#--
|
|
151
|
+
#: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
152
|
+
def handle_stream_chunk(chunk, state:, yielder:)
|
|
153
|
+
typed_chunk = chunk #: OpenAI::Models::Chat::ChatCompletionChunk
|
|
154
|
+
choice = typed_chunk.choices&.first
|
|
155
|
+
delta = choice&.delta
|
|
156
|
+
|
|
157
|
+
if delta
|
|
158
|
+
handle_text_delta(delta, state: state, yielder: yielder)
|
|
159
|
+
handle_reasoning_delta(delta, state: state, yielder: yielder)
|
|
160
|
+
handle_tool_call_deltas(delta, state: state, yielder: yielder)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if choice && finish_reason_is_tool_calls?(choice)
|
|
164
|
+
emit_tool_call_done_events(state: state, yielder: yielder)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return unless typed_chunk.usage
|
|
168
|
+
|
|
169
|
+
yielder << Riffer::StreamEvents::TokenUsageDone.new(
|
|
170
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
171
|
+
input_tokens: typed_chunk.usage.prompt_tokens,
|
|
172
|
+
output_tokens: typed_chunk.usage.completion_tokens
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
#--
|
|
178
|
+
#: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
179
|
+
def handle_text_delta(delta, state:, yielder:)
|
|
180
|
+
typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
|
|
181
|
+
content = typed_delta.content
|
|
182
|
+
return if content.nil? || content.empty?
|
|
183
|
+
|
|
184
|
+
state[:text] << content
|
|
185
|
+
yielder << Riffer::StreamEvents::TextDelta.new(content)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
#--
|
|
189
|
+
#: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
190
|
+
def handle_reasoning_delta(delta, state:, yielder:)
|
|
191
|
+
# The openai gem's typed Delta model strips fields not in OpenAI's spec
|
|
192
|
+
# (so +delta.reasoning+ raises NoMethodError), but the underlying data
|
|
193
|
+
# hash retains them. Access via +#[]+ which reads from BaseModel#@data.
|
|
194
|
+
reasoning = delta[:reasoning] if delta.respond_to?(:[])
|
|
195
|
+
return if reasoning.nil? || reasoning.empty?
|
|
196
|
+
|
|
197
|
+
state[:reasoning] << reasoning
|
|
198
|
+
yielder << Riffer::StreamEvents::ReasoningDelta.new(reasoning)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
#--
|
|
202
|
+
#: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
203
|
+
def handle_tool_call_deltas(delta, state:, yielder:)
|
|
204
|
+
typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
|
|
205
|
+
tool_calls = typed_delta.tool_calls
|
|
206
|
+
return if tool_calls.nil? || tool_calls.empty?
|
|
207
|
+
|
|
208
|
+
tool_calls.each do |tc|
|
|
209
|
+
entry = state[:tool_calls][tc.index] ||= {id: nil, name: nil, arguments: +""}
|
|
210
|
+
entry[:id] = tc.id if tc.id
|
|
211
|
+
|
|
212
|
+
fn = tc.function
|
|
213
|
+
next unless fn
|
|
214
|
+
|
|
215
|
+
entry[:name] = decode_tool_name(fn.name, tools: @current_tools) if fn.name
|
|
216
|
+
|
|
217
|
+
args_delta = fn.arguments
|
|
218
|
+
next if args_delta.nil? || args_delta.empty?
|
|
219
|
+
|
|
220
|
+
entry[:arguments] << args_delta
|
|
221
|
+
yielder << Riffer::StreamEvents::ToolCallDelta.new(
|
|
222
|
+
item_id: entry[:id] || "tool_#{tc.index}",
|
|
223
|
+
name: entry[:name],
|
|
224
|
+
arguments_delta: args_delta
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
#--
|
|
230
|
+
#: (state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
231
|
+
def emit_tool_call_done_events(state:, yielder:)
|
|
232
|
+
state[:tool_calls].each do |index, entry|
|
|
233
|
+
fallback = "tool_#{index}"
|
|
234
|
+
yielder << Riffer::StreamEvents::ToolCallDone.new(
|
|
235
|
+
item_id: entry[:id] || fallback,
|
|
236
|
+
call_id: entry[:id] || fallback,
|
|
237
|
+
name: entry[:name],
|
|
238
|
+
arguments: entry[:arguments]
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
state[:tool_calls] = {}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
#--
|
|
245
|
+
#: (untyped) -> bool
|
|
246
|
+
def finish_reason_is_tool_calls?(choice)
|
|
247
|
+
typed_choice = choice #: OpenAI::Models::Chat::ChatCompletionChunk::Choice
|
|
248
|
+
typed_choice.finish_reason.to_s == "tool_calls"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
#--
|
|
252
|
+
#: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
|
|
253
|
+
def convert_messages_to_chat_completions_format(messages)
|
|
254
|
+
messages.flat_map do |message|
|
|
255
|
+
case message
|
|
256
|
+
when Riffer::Messages::System
|
|
257
|
+
{role: "system", content: message.content}
|
|
258
|
+
when Riffer::Messages::User
|
|
259
|
+
if message.files.empty?
|
|
260
|
+
{role: "user", content: message.content}
|
|
261
|
+
else
|
|
262
|
+
content = [{type: "text", text: message.content}]
|
|
263
|
+
message.files.each { |file| content << convert_file_part_to_chat_completions_format(file) }
|
|
264
|
+
{role: "user", content: content}
|
|
265
|
+
end
|
|
266
|
+
when Riffer::Messages::Assistant
|
|
267
|
+
convert_assistant_to_chat_completions_format(message)
|
|
268
|
+
when Riffer::Messages::Tool
|
|
269
|
+
{role: "tool", tool_call_id: message.tool_call_id, content: message.content}
|
|
270
|
+
else
|
|
271
|
+
raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
#--
|
|
277
|
+
#: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
|
|
278
|
+
def convert_assistant_to_chat_completions_format(message)
|
|
279
|
+
msg = {role: "assistant"} #: Hash[Symbol, untyped]
|
|
280
|
+
msg[:content] = message.content if message.content && !message.content.empty?
|
|
281
|
+
|
|
282
|
+
unless message.tool_calls.empty?
|
|
283
|
+
msg[:tool_calls] = message.tool_calls.map do |tc|
|
|
284
|
+
{
|
|
285
|
+
id: tc.call_id,
|
|
286
|
+
type: "function",
|
|
287
|
+
function: {
|
|
288
|
+
name: encode_tool_name(tc.name),
|
|
289
|
+
arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
msg
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
#--
|
|
299
|
+
#: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
|
|
300
|
+
def convert_file_part_to_chat_completions_format(file)
|
|
301
|
+
if file.image?
|
|
302
|
+
image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
|
|
303
|
+
{type: "image_url", image_url: {url: image_url}}
|
|
304
|
+
else
|
|
305
|
+
data_uri = "data:#{file.media_type};base64,#{file.data}"
|
|
306
|
+
block = {type: "file", file: {file_data: data_uri}} #: Hash[Symbol, untyped]
|
|
307
|
+
block[:file][:filename] = file.filename if file.filename
|
|
308
|
+
block
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
#--
|
|
313
|
+
#: (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]
|
|
314
|
+
def convert_tool_to_chat_completions_format(tool)
|
|
315
|
+
{
|
|
316
|
+
type: "function",
|
|
317
|
+
function: {
|
|
318
|
+
name: encode_tool_name(tool.name),
|
|
319
|
+
description: tool.description,
|
|
320
|
+
parameters: tool.parameters_schema(strict: true),
|
|
321
|
+
strict: true
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
end
|
|
325
|
+
end
|