riffer 0.28.0 → 0.29.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/.agents/architecture.md +18 -11
- data/.agents/code-style.md +1 -1
- data/.agents/rbs-inline.md +2 -2
- data/.agents/testing.md +9 -5
- data/.release-please-manifest.json +1 -1
- data/AGENTS.md +17 -10
- data/CHANGELOG.md +19 -0
- data/README.md +17 -18
- data/Steepfile +7 -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 +125 -0
- data/lib/riffer/agent/run.rb +308 -0
- data/lib/riffer/agent/session/repair.rb +112 -0
- data/lib/riffer/agent/session.rb +268 -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 +234 -923
- data/lib/riffer/config.rb +14 -7
- data/lib/riffer/evals/evaluator.rb +13 -3
- data/lib/riffer/evals/judge.rb +2 -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 +1 -1
- data/lib/riffer/mcp/registration.rb +2 -3
- data/lib/riffer/mcp/registry.rb +3 -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} +5 -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 +19 -20
- data/lib/riffer/providers/anthropic.rb +27 -28
- data/lib/riffer/providers/base.rb +10 -9
- data/lib/riffer/providers/gemini.rb +15 -12
- data/lib/riffer/providers/mock.rb +41 -13
- data/lib/riffer/providers/open_ai.rb +24 -22
- data/lib/riffer/providers/open_router.rb +318 -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 +4 -3
- data/lib/riffer/runner/sequential.rb +1 -1
- data/lib/riffer/runner/threaded.rb +1 -1
- data/lib/riffer/runner.rb +1 -1
- data/lib/riffer/skills/activate_tool.rb +4 -3
- data/lib/riffer/skills/config.rb +1 -1
- data/lib/riffer/skills/context.rb +3 -3
- data/lib/riffer/skills/filesystem_backend.rb +7 -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/{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} +9 -9
- data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +2 -1
- data/sig/generated/riffer/agent/config.rbs +119 -0
- data/sig/generated/riffer/agent/context.rbs +91 -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 +145 -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 +143 -342
- data/sig/generated/riffer/config.rbs +17 -5
- data/sig/generated/riffer/evals/judge.rbs +2 -2
- data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
- data/sig/generated/riffer/helpers.rbs +0 -1
- 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} +5 -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 +4 -4
- data/sig/generated/riffer/providers/base.rbs +10 -10
- data/sig/generated/riffer/providers/gemini.rbs +4 -4
- data/sig/generated/riffer/providers/mock.rbs +25 -5
- data/sig/generated/riffer/providers/open_ai.rbs +4 -4
- 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 +2 -2
- data/sig/generated/riffer/runner/sequential.rbs +2 -2
- data/sig/generated/riffer/runner/threaded.rbs +2 -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 +1 -1
- data/sig/generated/riffer/skills/context.rbs +2 -2
- data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
- data/sig/generated/riffer/tool.rbs +5 -5
- 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} +12 -12
- data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
- data/sig/stubs/agent_ivars.rbs +7 -0
- data/sig/stubs/async.rbs +24 -0
- data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
- data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
- data/sig/stubs/extend_self.rbs +11 -0
- data/sig/stubs/lib_ivars.rbs +101 -0
- data/sig/stubs/mcp_sdk.rbs +22 -0
- data/sig/stubs/provider_ivars.rbs +36 -0
- data/sig/stubs/provider_sdk_methods.rbs +50 -0
- data/sig/stubs/zeitwerk.rbs +12 -0
- metadata +54 -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
|
@@ -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
|
|
@@ -72,12 +72,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
#--
|
|
75
|
-
#: (OpenAI::Models::Responses::Response) -> Riffer::TokenUsage?
|
|
75
|
+
#: (OpenAI::Models::Responses::Response) -> Riffer::Providers::TokenUsage?
|
|
76
76
|
def extract_token_usage(response)
|
|
77
77
|
usage = response.usage
|
|
78
78
|
return nil unless usage
|
|
79
79
|
|
|
80
|
-
Riffer::TokenUsage.new(
|
|
80
|
+
Riffer::Providers::TokenUsage.new(
|
|
81
81
|
input_tokens: usage.input_tokens,
|
|
82
82
|
output_tokens: usage.output_tokens
|
|
83
83
|
)
|
|
@@ -89,10 +89,10 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
89
89
|
text_content = ""
|
|
90
90
|
|
|
91
91
|
response.output.each do |item|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
|
|
93
|
+
|
|
94
|
+
text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
|
|
95
|
+
text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
text_content
|
|
@@ -101,16 +101,16 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
101
101
|
#--
|
|
102
102
|
#: (OpenAI::Models::Responses::Response) -> Array[Riffer::Messages::Assistant::ToolCall]
|
|
103
103
|
def extract_tool_calls(response)
|
|
104
|
-
tool_calls = []
|
|
104
|
+
tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
|
|
105
105
|
|
|
106
106
|
response.output.each do |item|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
next unless item.is_a?(::OpenAI::Models::Responses::ResponseFunctionToolCall)
|
|
108
|
+
|
|
109
|
+
tool_calls << Riffer::Messages::Assistant::ToolCall.new(
|
|
110
|
+
call_id: item.call_id,
|
|
111
|
+
name: decode_tool_name(item.name, tools: @current_tools),
|
|
112
|
+
arguments: item.arguments
|
|
113
|
+
)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
tool_calls
|
|
@@ -121,7 +121,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
121
121
|
def execute_stream(params, yielder)
|
|
122
122
|
current_state = {
|
|
123
123
|
tool_info: {}
|
|
124
|
-
}
|
|
124
|
+
} #: Hash[Symbol, untyped]
|
|
125
125
|
|
|
126
126
|
stream = @client.responses.stream(params)
|
|
127
127
|
begin
|
|
@@ -224,7 +224,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
224
224
|
return unless usage
|
|
225
225
|
|
|
226
226
|
yielder << Riffer::StreamEvents::TokenUsageDone.new(
|
|
227
|
-
token_usage: Riffer::TokenUsage.new(
|
|
227
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
228
228
|
input_tokens: usage.input_tokens,
|
|
229
229
|
output_tokens: usage.output_tokens
|
|
230
230
|
)
|
|
@@ -242,11 +242,11 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
242
242
|
def handle_output_item_done_web_search(event, yielder:)
|
|
243
243
|
action = event.item.action
|
|
244
244
|
case action
|
|
245
|
-
when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
|
|
245
|
+
when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::OpenPage
|
|
246
246
|
# OpenPage carries a url but no query or sources, so it doesn't fit
|
|
247
247
|
# WebSearchDone — emit as a status notification instead.
|
|
248
248
|
yielder << Riffer::StreamEvents::WebSearchStatus.new("open_page", url: action.url)
|
|
249
|
-
when OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
|
|
249
|
+
when ::OpenAI::Models::Responses::ResponseFunctionWebSearch::Action::Search
|
|
250
250
|
sources = (action.sources || []).map { |s| {title: nil, url: s.url} }
|
|
251
251
|
yielder << Riffer::StreamEvents::WebSearchDone.new(action.query, sources: sources)
|
|
252
252
|
end
|
|
@@ -275,6 +275,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
275
275
|
call_id: message.tool_call_id,
|
|
276
276
|
output: message.content
|
|
277
277
|
}
|
|
278
|
+
else
|
|
279
|
+
raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
|
|
278
280
|
end
|
|
279
281
|
end
|
|
280
282
|
end
|
|
@@ -285,7 +287,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
285
287
|
if message.tool_calls.empty?
|
|
286
288
|
{role: "assistant", content: message.content}
|
|
287
289
|
else
|
|
288
|
-
items = []
|
|
290
|
+
items = [] #: Array[Hash[Symbol, untyped]]
|
|
289
291
|
items << {type: "message", role: "assistant", content: message.content} if message.content && !message.content.empty?
|
|
290
292
|
message.tool_calls.each do |tc|
|
|
291
293
|
items << {
|
|
@@ -300,7 +302,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
300
302
|
end
|
|
301
303
|
|
|
302
304
|
#--
|
|
303
|
-
#: (Riffer::FilePart) -> Hash[Symbol, untyped]
|
|
305
|
+
#: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
|
|
304
306
|
def convert_file_part_to_openai_format(file)
|
|
305
307
|
if file.image?
|
|
306
308
|
image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
|
|
@@ -0,0 +1,318 @@
|
|
|
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]) -> OpenAI::Models::Chat::ChatCompletion
|
|
68
|
+
def execute_generate(params)
|
|
69
|
+
@client.chat.completions.create(**params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#--
|
|
73
|
+
#: (OpenAI::Models::Chat::ChatCompletion) -> Riffer::Providers::TokenUsage?
|
|
74
|
+
def extract_token_usage(response)
|
|
75
|
+
usage = response.usage
|
|
76
|
+
return nil unless usage
|
|
77
|
+
|
|
78
|
+
Riffer::Providers::TokenUsage.new(
|
|
79
|
+
input_tokens: usage.prompt_tokens,
|
|
80
|
+
output_tokens: usage.completion_tokens
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#--
|
|
85
|
+
#: (OpenAI::Models::Chat::ChatCompletion) -> String
|
|
86
|
+
def extract_content(response)
|
|
87
|
+
response.choices.first&.message&.content || ""
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#--
|
|
91
|
+
#: (OpenAI::Models::Chat::ChatCompletion) -> Array[Riffer::Messages::Assistant::ToolCall]
|
|
92
|
+
def extract_tool_calls(response)
|
|
93
|
+
message = response.choices.first&.message
|
|
94
|
+
return [] unless message
|
|
95
|
+
|
|
96
|
+
tool_calls = message.tool_calls
|
|
97
|
+
return [] if tool_calls.nil? || tool_calls.empty?
|
|
98
|
+
|
|
99
|
+
tool_calls.filter_map do |tc|
|
|
100
|
+
next unless tc.is_a?(::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall)
|
|
101
|
+
|
|
102
|
+
Riffer::Messages::Assistant::ToolCall.new(
|
|
103
|
+
call_id: tc.id,
|
|
104
|
+
name: decode_tool_name(tc.function.name, tools: @current_tools),
|
|
105
|
+
arguments: tc.function.arguments
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#--
|
|
111
|
+
#: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
|
|
112
|
+
def execute_stream(params, yielder)
|
|
113
|
+
# OpenRouter omits usage from streams unless explicitly opted in.
|
|
114
|
+
stream_options = (params[:stream_options] || {}).merge(include_usage: true)
|
|
115
|
+
stream_params = params.merge(stream_options: stream_options)
|
|
116
|
+
|
|
117
|
+
state = {
|
|
118
|
+
text: +"",
|
|
119
|
+
reasoning: +"",
|
|
120
|
+
tool_calls: {}
|
|
121
|
+
} #: Hash[Symbol, untyped]
|
|
122
|
+
|
|
123
|
+
# Use stream_raw (not stream) — the latter yields a higher-level
|
|
124
|
+
# ChatChunkEvent helper that aggregates content/tool calls into typed
|
|
125
|
+
# events. We want raw ChatCompletionChunk objects with
|
|
126
|
+
# +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
|
|
127
|
+
# ourselves.
|
|
128
|
+
stream = @client.chat.completions.stream_raw(**stream_params)
|
|
129
|
+
begin
|
|
130
|
+
stream.each do |chunk|
|
|
131
|
+
handle_stream_chunk(chunk, state: state, yielder: yielder)
|
|
132
|
+
end
|
|
133
|
+
ensure
|
|
134
|
+
# The OpenAI SDK does not auto-close the SSE socket on iteration
|
|
135
|
+
# interrupt, so close explicitly. Idempotent and a no-op after EOF.
|
|
136
|
+
stream.close
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Chat Completions has no per-tool terminal event, so flush any leftover
|
|
140
|
+
# tool calls here in case finish_reason is missing or not "tool_calls".
|
|
141
|
+
emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?
|
|
142
|
+
|
|
143
|
+
yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
|
|
144
|
+
yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
#--
|
|
148
|
+
#: (OpenAI::Models::Chat::ChatCompletionChunk, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
149
|
+
def handle_stream_chunk(chunk, state:, yielder:)
|
|
150
|
+
choice = chunk.choices&.first
|
|
151
|
+
delta = choice&.delta
|
|
152
|
+
|
|
153
|
+
if delta
|
|
154
|
+
handle_text_delta(delta, state: state, yielder: yielder)
|
|
155
|
+
handle_reasoning_delta(delta, state: state, yielder: yielder)
|
|
156
|
+
handle_tool_call_deltas(delta, state: state, yielder: yielder)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if choice && finish_reason_is_tool_calls?(choice)
|
|
160
|
+
emit_tool_call_done_events(state: state, yielder: yielder)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
return unless chunk.usage
|
|
164
|
+
|
|
165
|
+
yielder << Riffer::StreamEvents::TokenUsageDone.new(
|
|
166
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
167
|
+
input_tokens: chunk.usage.prompt_tokens,
|
|
168
|
+
output_tokens: chunk.usage.completion_tokens
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
#--
|
|
174
|
+
#: (OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
175
|
+
def handle_text_delta(delta, state:, yielder:)
|
|
176
|
+
content = delta.content
|
|
177
|
+
return if content.nil? || content.empty?
|
|
178
|
+
|
|
179
|
+
state[:text] << content
|
|
180
|
+
yielder << Riffer::StreamEvents::TextDelta.new(content)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
#--
|
|
184
|
+
#: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
185
|
+
def handle_reasoning_delta(delta, state:, yielder:)
|
|
186
|
+
# The openai gem's typed Delta model strips fields not in OpenAI's spec
|
|
187
|
+
# (so +delta.reasoning+ raises NoMethodError), but the underlying data
|
|
188
|
+
# hash retains them. Access via +#[]+ which reads from BaseModel#@data.
|
|
189
|
+
reasoning = delta[:reasoning] if delta.respond_to?(:[])
|
|
190
|
+
return if reasoning.nil? || reasoning.empty?
|
|
191
|
+
|
|
192
|
+
state[:reasoning] << reasoning
|
|
193
|
+
yielder << Riffer::StreamEvents::ReasoningDelta.new(reasoning)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
#--
|
|
197
|
+
#: (OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
198
|
+
def handle_tool_call_deltas(delta, state:, yielder:)
|
|
199
|
+
tool_calls = delta.tool_calls
|
|
200
|
+
return if tool_calls.nil? || tool_calls.empty?
|
|
201
|
+
|
|
202
|
+
tool_calls.each do |tc|
|
|
203
|
+
entry = state[:tool_calls][tc.index] ||= {id: nil, name: nil, arguments: +""}
|
|
204
|
+
entry[:id] = tc.id if tc.id
|
|
205
|
+
|
|
206
|
+
fn = tc.function
|
|
207
|
+
next unless fn
|
|
208
|
+
|
|
209
|
+
entry[:name] = decode_tool_name(fn.name, tools: @current_tools) if fn.name
|
|
210
|
+
|
|
211
|
+
args_delta = fn.arguments
|
|
212
|
+
next if args_delta.nil? || args_delta.empty?
|
|
213
|
+
|
|
214
|
+
entry[:arguments] << args_delta
|
|
215
|
+
yielder << Riffer::StreamEvents::ToolCallDelta.new(
|
|
216
|
+
item_id: entry[:id] || "tool_#{tc.index}",
|
|
217
|
+
name: entry[:name],
|
|
218
|
+
arguments_delta: args_delta
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
#--
|
|
224
|
+
#: (state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
|
|
225
|
+
def emit_tool_call_done_events(state:, yielder:)
|
|
226
|
+
state[:tool_calls].each do |index, entry|
|
|
227
|
+
fallback = "tool_#{index}"
|
|
228
|
+
yielder << Riffer::StreamEvents::ToolCallDone.new(
|
|
229
|
+
item_id: entry[:id] || fallback,
|
|
230
|
+
call_id: entry[:id] || fallback,
|
|
231
|
+
name: entry[:name],
|
|
232
|
+
arguments: entry[:arguments]
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
state[:tool_calls] = {}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
#--
|
|
239
|
+
#: (OpenAI::Models::Chat::ChatCompletionChunk::Choice) -> bool
|
|
240
|
+
def finish_reason_is_tool_calls?(choice)
|
|
241
|
+
choice.finish_reason.to_s == "tool_calls"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
#--
|
|
245
|
+
#: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
|
|
246
|
+
def convert_messages_to_chat_completions_format(messages)
|
|
247
|
+
messages.flat_map do |message|
|
|
248
|
+
case message
|
|
249
|
+
when Riffer::Messages::System
|
|
250
|
+
{role: "system", content: message.content}
|
|
251
|
+
when Riffer::Messages::User
|
|
252
|
+
if message.files.empty?
|
|
253
|
+
{role: "user", content: message.content}
|
|
254
|
+
else
|
|
255
|
+
content = [{type: "text", text: message.content}]
|
|
256
|
+
message.files.each { |file| content << convert_file_part_to_chat_completions_format(file) }
|
|
257
|
+
{role: "user", content: content}
|
|
258
|
+
end
|
|
259
|
+
when Riffer::Messages::Assistant
|
|
260
|
+
convert_assistant_to_chat_completions_format(message)
|
|
261
|
+
when Riffer::Messages::Tool
|
|
262
|
+
{role: "tool", tool_call_id: message.tool_call_id, content: message.content}
|
|
263
|
+
else
|
|
264
|
+
raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
#--
|
|
270
|
+
#: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
|
|
271
|
+
def convert_assistant_to_chat_completions_format(message)
|
|
272
|
+
msg = {role: "assistant"} #: Hash[Symbol, untyped]
|
|
273
|
+
msg[:content] = message.content if message.content && !message.content.empty?
|
|
274
|
+
|
|
275
|
+
unless message.tool_calls.empty?
|
|
276
|
+
msg[:tool_calls] = message.tool_calls.map do |tc|
|
|
277
|
+
{
|
|
278
|
+
id: tc.call_id,
|
|
279
|
+
type: "function",
|
|
280
|
+
function: {
|
|
281
|
+
name: encode_tool_name(tc.name),
|
|
282
|
+
arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
msg
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
#--
|
|
292
|
+
#: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
|
|
293
|
+
def convert_file_part_to_chat_completions_format(file)
|
|
294
|
+
if file.image?
|
|
295
|
+
image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
|
|
296
|
+
{type: "image_url", image_url: {url: image_url}}
|
|
297
|
+
else
|
|
298
|
+
data_uri = "data:#{file.media_type};base64,#{file.data}"
|
|
299
|
+
block = {type: "file", file: {file_data: data_uri}} #: Hash[Symbol, untyped]
|
|
300
|
+
block[:file][:filename] = file.filename if file.filename
|
|
301
|
+
block
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
#--
|
|
306
|
+
#: (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]
|
|
307
|
+
def convert_tool_to_chat_completions_format(tool)
|
|
308
|
+
{
|
|
309
|
+
type: "function",
|
|
310
|
+
function: {
|
|
311
|
+
name: encode_tool_name(tool.name),
|
|
312
|
+
description: tool.description,
|
|
313
|
+
parameters: tool.parameters_schema(strict: true),
|
|
314
|
+
strict: true
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -10,6 +10,7 @@ class Riffer::Providers::Repository
|
|
|
10
10
|
azure_openai: -> { Riffer::Providers::AzureOpenAI },
|
|
11
11
|
gemini: -> { Riffer::Providers::Gemini },
|
|
12
12
|
openai: -> { Riffer::Providers::OpenAI },
|
|
13
|
+
openrouter: -> { Riffer::Providers::OpenRouter },
|
|
13
14
|
mock: -> { Riffer::Providers::Mock }
|
|
14
15
|
}.freeze #: Hash[Symbol, ^() -> singleton(Riffer::Providers::Base)]
|
|
15
16
|
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Tracks input tokens, output tokens, and optional cache statistics.
|
|
7
7
|
#
|
|
8
|
-
# token_usage = Riffer::TokenUsage.new(input_tokens: 100, output_tokens: 50)
|
|
8
|
+
# token_usage = Riffer::Providers::TokenUsage.new(input_tokens: 100, output_tokens: 50)
|
|
9
9
|
# token_usage.total_tokens # => 150
|
|
10
10
|
#
|
|
11
11
|
# combined = token_usage1 + token_usage2 # Combine multiple token usage objects
|
|
12
12
|
#
|
|
13
|
-
class Riffer::TokenUsage
|
|
13
|
+
class Riffer::Providers::TokenUsage
|
|
14
14
|
# Number of tokens in the input/prompt.
|
|
15
15
|
attr_reader :input_tokens #: Integer
|
|
16
16
|
|
|
@@ -43,9 +43,9 @@ class Riffer::TokenUsage
|
|
|
43
43
|
# Combines two TokenUsage objects for cumulative tracking.
|
|
44
44
|
#
|
|
45
45
|
#--
|
|
46
|
-
#: (Riffer::TokenUsage) -> Riffer::TokenUsage
|
|
46
|
+
#: (Riffer::Providers::TokenUsage) -> Riffer::Providers::TokenUsage
|
|
47
47
|
def +(other)
|
|
48
|
-
Riffer::TokenUsage.new(
|
|
48
|
+
Riffer::Providers::TokenUsage.new(
|
|
49
49
|
input_tokens: input_tokens + other.input_tokens,
|
|
50
50
|
output_tokens: output_tokens + other.output_tokens,
|
|
51
51
|
cache_creation_tokens: add_nullable(cache_creation_tokens, other.cache_creation_tokens),
|
data/lib/riffer/providers.rb
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# - Riffer::Providers::OpenAI - OpenAI GPT models
|
|
8
8
|
# - Riffer::Providers::AzureOpenAI - Azure OpenAI GPT models
|
|
9
9
|
# - Riffer::Providers::AmazonBedrock - AWS Bedrock models
|
|
10
|
+
# - Riffer::Providers::OpenRouter - OpenRouter unified gateway
|
|
10
11
|
# - Riffer::Providers::Mock - Mock provider for testing
|
|
11
12
|
module Riffer::Providers
|
|
12
13
|
end
|
data/lib/riffer/runner/fibers.rb
CHANGED
|
@@ -28,7 +28,7 @@ class Riffer::Runner::Fibers < Riffer::Runner
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
#--
|
|
31
|
-
#: (Array[untyped], context:
|
|
31
|
+
#: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
|
|
32
32
|
def map(items, context:, &block)
|
|
33
33
|
return [] if items.empty?
|
|
34
34
|
|
|
@@ -37,8 +37,9 @@ class Riffer::Runner::Fibers < Riffer::Runner
|
|
|
37
37
|
|
|
38
38
|
Async do
|
|
39
39
|
barrier = Async::Barrier.new
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
max = @max_concurrency
|
|
41
|
+
parent = if max
|
|
42
|
+
Async::Semaphore.new(max, parent: barrier)
|
|
42
43
|
else
|
|
43
44
|
barrier
|
|
44
45
|
end
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
#
|
|
8
8
|
class Riffer::Runner::Sequential < Riffer::Runner
|
|
9
9
|
#--
|
|
10
|
-
#: (Array[untyped], context:
|
|
10
|
+
#: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
|
|
11
11
|
def map(items, context:, &block)
|
|
12
12
|
items.map(&block)
|
|
13
13
|
end
|
|
@@ -25,7 +25,7 @@ class Riffer::Runner::Threaded < Riffer::Runner
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
#--
|
|
28
|
-
#: (Array[untyped], context:
|
|
28
|
+
#: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
|
|
29
29
|
def map(items, context:, &block)
|
|
30
30
|
return [] if items.empty?
|
|
31
31
|
|
data/lib/riffer/runner.rb
CHANGED
|
@@ -19,7 +19,7 @@ class Riffer::Runner
|
|
|
19
19
|
# Raises NotImplementedError if not implemented by subclass.
|
|
20
20
|
#
|
|
21
21
|
#--
|
|
22
|
-
#: (Array[untyped], context:
|
|
22
|
+
#: (Array[untyped], context: Riffer::Agent::Context?) { (untyped) -> untyped } -> Array[untyped]
|
|
23
23
|
def map(items, context:, &block)
|
|
24
24
|
raise NotImplementedError, "#{self.class} must implement #map"
|
|
25
25
|
end
|
|
@@ -19,13 +19,14 @@ class Riffer::Skills::ActivateTool < Riffer::Tool
|
|
|
19
19
|
|
|
20
20
|
# Activates a skill by name and returns its body.
|
|
21
21
|
#
|
|
22
|
-
# [context]
|
|
22
|
+
# [context] the agent's +Riffer::Agent::Context+, exposing +#skills+
|
|
23
|
+
# (a +Riffer::Skills::Context+).
|
|
23
24
|
# [name] the skill name to activate.
|
|
24
25
|
#
|
|
25
26
|
#--
|
|
26
|
-
#: (context:
|
|
27
|
+
#: (context: Riffer::Agent::Context?, name: String) -> Riffer::Tools::Response
|
|
27
28
|
def call(context:, name:)
|
|
28
|
-
skills_context = context&.
|
|
29
|
+
skills_context = context&.skills
|
|
29
30
|
return error("Skills not configured") unless skills_context
|
|
30
31
|
|
|
31
32
|
text(skills_context.activate(name))
|
data/lib/riffer/skills/config.rb
CHANGED
|
@@ -66,7 +66,7 @@ class Riffer::Skills::Config
|
|
|
66
66
|
# Returns the configured override when set, or +nil+ when unset. The
|
|
67
67
|
# global fallback to <tt>Riffer.config.skills.default_activate_tool</tt>
|
|
68
68
|
# is applied by the agent at resolution time (see
|
|
69
|
-
# Riffer::Agent
|
|
69
|
+
# Riffer::Agent#resolve_tools), not by this getter.
|
|
70
70
|
#
|
|
71
71
|
# The override must be a subclass of Riffer::Tool.
|
|
72
72
|
#
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
# Coordinates skill discovery, activation, and prompt rendering.
|
|
7
7
|
# Tracks activations with caching to avoid redundant backend reads.
|
|
8
8
|
#
|
|
9
|
-
# Built by the agent
|
|
10
|
-
#
|
|
9
|
+
# Built by the agent during +Agent.new+ and exposed to tools via
|
|
10
|
+
# <tt>context.skills</tt> on the agent's +Riffer::Agent::Context+.
|
|
11
11
|
#
|
|
12
12
|
# See Riffer::Skills::Backend, Riffer::Skills::Frontmatter.
|
|
13
13
|
class Riffer::Skills::Context
|
|
@@ -66,7 +66,7 @@ class Riffer::Skills::Context
|
|
|
66
66
|
#: () -> String
|
|
67
67
|
def system_prompt
|
|
68
68
|
available = available_skills
|
|
69
|
-
parts = []
|
|
69
|
+
parts = [] #: Array[String]
|
|
70
70
|
parts << @adapter.render_catalog(available) unless available.empty?
|
|
71
71
|
@activated.each_value { |body| parts << body }
|
|
72
72
|
parts.join("\n\n")
|
|
@@ -31,8 +31,9 @@ class Riffer::Skills::FilesystemBackend < Riffer::Skills::Backend
|
|
|
31
31
|
#--
|
|
32
32
|
#: () -> Array[Riffer::Skills::Frontmatter]
|
|
33
33
|
def list_skills
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
cache = {} #: Hash[String, String]
|
|
35
|
+
@skills_cache = cache
|
|
36
|
+
frontmatters = [] #: Array[Riffer::Skills::Frontmatter]
|
|
36
37
|
|
|
37
38
|
@paths.each do |path|
|
|
38
39
|
next unless File.directory?(path)
|
|
@@ -45,10 +46,10 @@ class Riffer::Skills::FilesystemBackend < Riffer::Skills::Backend
|
|
|
45
46
|
frontmatter = Riffer::Skills::Frontmatter.parse_frontmatter(File.read(skill_file))
|
|
46
47
|
|
|
47
48
|
validate_dirname_matches_name!(dirname, frontmatter.name)
|
|
48
|
-
next if
|
|
49
|
+
next if cache.key?(frontmatter.name)
|
|
49
50
|
|
|
50
51
|
frontmatters << frontmatter
|
|
51
|
-
|
|
52
|
+
cache[frontmatter.name] = dir
|
|
52
53
|
end
|
|
53
54
|
end
|
|
54
55
|
|
|
@@ -65,7 +66,8 @@ class Riffer::Skills::FilesystemBackend < Riffer::Skills::Backend
|
|
|
65
66
|
#: (String) -> String
|
|
66
67
|
def read_skill(name)
|
|
67
68
|
list_skills unless @skills_cache
|
|
68
|
-
|
|
69
|
+
cache = @skills_cache #: Hash[String, String]
|
|
70
|
+
dir = cache[name]
|
|
69
71
|
raise Riffer::ArgumentError, "Skill not found: '#{name}'" unless dir
|
|
70
72
|
|
|
71
73
|
_, body = Riffer::Skills::Frontmatter.parse(File.read(File.join(dir, SKILL_FILENAME)))
|
|
@@ -15,7 +15,7 @@ class Riffer::Skills::MarkdownAdapter < Riffer::Skills::Adapter
|
|
|
15
15
|
#--
|
|
16
16
|
#: (Array[Riffer::Skills::Frontmatter]) -> String
|
|
17
17
|
def render_catalog(skills)
|
|
18
|
-
lines = []
|
|
18
|
+
lines = [] #: Array[String]
|
|
19
19
|
lines << "## Available Skills"
|
|
20
20
|
lines << ""
|
|
21
21
|
lines << "When a user's request matches a skill description below, call the `#{skill_activate_tool.name}` tool with the skill name. After activation, follow the skill's instructions."
|
|
@@ -16,7 +16,7 @@ class Riffer::Skills::XmlAdapter < Riffer::Skills::Adapter
|
|
|
16
16
|
#--
|
|
17
17
|
#: (Array[Riffer::Skills::Frontmatter]) -> String
|
|
18
18
|
def render_catalog(skills)
|
|
19
|
-
lines = []
|
|
19
|
+
lines = [] #: Array[String]
|
|
20
20
|
lines << "When a user's request matches a skill description below, call the `#{skill_activate_tool.name}` tool with the skill name. After activation, follow the skill's instructions."
|
|
21
21
|
lines << ""
|
|
22
22
|
lines << "<available_skills>"
|
|
@@ -26,7 +26,7 @@ class Riffer::StreamEvents::Interrupt < Riffer::StreamEvents::Base
|
|
|
26
26
|
#--
|
|
27
27
|
#: () -> Hash[Symbol, untyped]
|
|
28
28
|
def to_h
|
|
29
|
-
h = {role: @role, interrupt: true}
|
|
29
|
+
h = {role: @role, interrupt: true} #: Hash[Symbol, untyped]
|
|
30
30
|
h[:reason] = @reason if @reason
|
|
31
31
|
h[:healed_tool_call_ids] = @healed_tool_call_ids unless @healed_tool_call_ids.empty?
|
|
32
32
|
h
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
#
|
|
12
12
|
class Riffer::StreamEvents::TokenUsageDone < Riffer::StreamEvents::Base
|
|
13
13
|
# The token usage data for this response.
|
|
14
|
-
attr_reader :token_usage #: Riffer::TokenUsage
|
|
14
|
+
attr_reader :token_usage #: Riffer::Providers::TokenUsage
|
|
15
15
|
|
|
16
16
|
#--
|
|
17
|
-
#: (token_usage: Riffer::TokenUsage, ?role: Symbol) -> void
|
|
17
|
+
#: (token_usage: Riffer::Providers::TokenUsage, ?role: Symbol) -> void
|
|
18
18
|
def initialize(token_usage:, role: :assistant)
|
|
19
19
|
super(role: role)
|
|
20
20
|
@token_usage = token_usage
|
|
@@ -26,7 +26,7 @@ class Riffer::StreamEvents::WebSearchStatus < Riffer::StreamEvents::Base
|
|
|
26
26
|
#--
|
|
27
27
|
#: () -> Hash[Symbol, untyped]
|
|
28
28
|
def to_h
|
|
29
|
-
h = {role: @role, status: @status}
|
|
29
|
+
h = {role: @role, status: @status} #: Hash[Symbol, untyped]
|
|
30
30
|
h[:url] = @url if @url
|
|
31
31
|
h[:query] = @query if @query
|
|
32
32
|
h
|