brute 4.3.2 → 5.0.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/lib/brute/completion/async_faraday.rb +38 -0
- data/lib/brute/completion/lang_chain.rb +185 -0
- data/lib/brute/completion/llmrb.rb +182 -0
- data/lib/brute/completion/open_router.rb +54 -42
- data/lib/brute/completion/ruby_llm.rb +198 -0
- data/lib/brute/contrib/otel.rb +208 -0
- data/lib/brute/hooks.rb +149 -31
- data/lib/brute/message_transport/lang_chain.rb +36 -0
- data/lib/brute/message_transport/llm.rb +6 -0
- data/lib/brute/message_transport/open_router.rb +6 -0
- data/lib/brute/message_transport/ruby_llm.rb +6 -0
- data/lib/brute/message_transport.rb +8 -0
- data/lib/brute/middleware/000_base.rb +61 -0
- data/lib/brute/middleware/002_session_log.rb +1 -1
- data/lib/brute/middleware/004_summarize.rb +1 -1
- data/lib/brute/middleware/005_tracing.rb +1 -1
- data/lib/brute/middleware/006_loop.rb +1 -1
- data/lib/brute/middleware/008_checkpoint.rb +1 -1
- data/lib/brute/middleware/010_max_iterations.rb +1 -1
- data/lib/brute/middleware/020_system_prompt.rb +1 -1
- data/lib/brute/middleware/025_skills.rb +1 -1
- data/lib/brute/middleware/040_compaction_check.rb +1 -1
- data/lib/brute/middleware/060_questions.rb +1 -1
- data/lib/brute/middleware/070_tool_pipeline.rb +60 -47
- data/lib/brute/middleware/event_handler.rb +1 -1
- data/lib/brute/middleware/user_queue.rb +1 -1
- data/lib/brute/turn/agent_pipeline.rb +3 -4
- data/lib/brute/turn/pipeline.rb +151 -2
- data/lib/brute/usage_detection/lang_chain.rb +44 -0
- data/lib/brute/usage_detection/llmrb.rb +53 -0
- data/lib/brute/usage_detection/open_router.rb +62 -0
- data/lib/brute/usage_detection/ruby_llm.rb +55 -0
- data/lib/brute/usage_detection/usage.rb +51 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +95 -3
- metadata +83 -8
- data/lib/brute/changelog.rb +0 -322
- data/lib/brute/deprecate.rb +0 -132
- data/lib/brute/middleware/001_otel_span.rb +0 -79
- data/lib/brute/middleware/015_otel_token_usage.rb +0 -44
- data/lib/brute/middleware/073_otel_tool_call.rb +0 -51
- data/lib/brute/middleware/075_otel_tool_results.rb +0 -48
- data/lib/brute/middleware/open_router.rb +0 -56
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9b592266887d7f4099f89d132ae3fb0af1b7b360f8b4a819c10b300b8e0484e
|
|
4
|
+
data.tar.gz: d761c4ab24c37596acf52235e5e640177ec4b4d6ba528c5c9c0936452fdffc88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98083c184fcea3e2213fe6b244a151aabdb4d136ef11bd130d35ecf4a07881925b961df267e75de5574927b5e8cf18c2fda9f9fea1ec10e57740c76e8c0a2c23
|
|
7
|
+
data.tar.gz: 41f4df4d86383fb6eb3cddaeb4d64e27dd37f3bccd09e5e80d2f5b98eaac7f1b7df886c16cb77a714b8d1888becf3cca5495723d8a6e9539771b36623bb413c0
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
|
|
6
|
+
module Brute
|
|
7
|
+
module Completion
|
|
8
|
+
# Faraday's own default adapter is Net::HTTP, which works under Async but
|
|
9
|
+
# opens a fresh connection for every request. Provider gems build their
|
|
10
|
+
# own Faraday connections and give no seam to configure them, so the
|
|
11
|
+
# adapter is swapped at the only place it can be: Faraday's default.
|
|
12
|
+
#
|
|
13
|
+
# Called by each completion whose provider gem rides on Faraday, after
|
|
14
|
+
# that gem is required — so it only fires when Faraday is genuinely in
|
|
15
|
+
# play, and an app that uses a completion which doesn't (llm.rb speaks
|
|
16
|
+
# Net::HTTP directly) never loads any of it.
|
|
17
|
+
def self.async_faraday!
|
|
18
|
+
return false unless defined?(::Faraday)
|
|
19
|
+
|
|
20
|
+
require "async/http/faraday/default"
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
__END__
|
|
27
|
+
|
|
28
|
+
describe "brute/completion/async_faraday" do
|
|
29
|
+
it "points Faraday's default adapter at async-http, and does nothing without Faraday" do
|
|
30
|
+
require "faraday"
|
|
31
|
+
Brute::Completion.async_faraday!.should.be.true
|
|
32
|
+
::Faraday.default_adapter.should == :async_http
|
|
33
|
+
|
|
34
|
+
# Idempotent: a second completion constructing does not undo the first.
|
|
35
|
+
Brute::Completion.async_faraday!.should.be.true
|
|
36
|
+
::Faraday.default_adapter.should == :async_http
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/hooks"
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
module Completion
|
|
9
|
+
# Completion backed by the langchainrb gem
|
|
10
|
+
# (https://github.com/patterns-ai-core/langchainrb). Its LLM classes wrap
|
|
11
|
+
# many providers; build one and hand it over:
|
|
12
|
+
#
|
|
13
|
+
# Brute.agent
|
|
14
|
+
# .use(Brute::Middleware::SystemPrompt)
|
|
15
|
+
# .run(Brute::Completion::LangChain.new(
|
|
16
|
+
# llm: Langchain::LLM::OpenAI.new(api_key: ENV["OPENAI_API_KEY"]),
|
|
17
|
+
# ))
|
|
18
|
+
#
|
|
19
|
+
# llm: a Langchain::LLM instance (required; client: is an alias)
|
|
20
|
+
# model: model id override (falls back to env[:model], then the
|
|
21
|
+
# llm instance's own default)
|
|
22
|
+
# tools: tools list, any shape Tools::Adapter accepts
|
|
23
|
+
# temperature: sampling temperature (default 0.7)
|
|
24
|
+
class LangChain
|
|
25
|
+
include Brute::Hooks
|
|
26
|
+
|
|
27
|
+
DEFAULT_TEMPERATURE = 0.7
|
|
28
|
+
|
|
29
|
+
def initialize(llm: nil, client: nil, **options)
|
|
30
|
+
# Brute depends on no LLM library: the provider gem is required here,
|
|
31
|
+
# at point of use, and only for this completion.
|
|
32
|
+
begin
|
|
33
|
+
require "langchain"
|
|
34
|
+
rescue LoadError
|
|
35
|
+
raise LoadError, "#{self.class} needs the 'langchainrb' gem — add `gem \"langchainrb\"` to your Gemfile."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Brute::Completion.async_faraday!
|
|
39
|
+
|
|
40
|
+
@llm = llm || client or
|
|
41
|
+
raise ArgumentError, "#{self.class} needs an llm: option, e.g. Langchain::LLM::OpenAI.new(api_key: ...)"
|
|
42
|
+
|
|
43
|
+
@options = options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(env)
|
|
47
|
+
emit(BEFORE_LLM_EVENT, env)
|
|
48
|
+
|
|
49
|
+
response = nil
|
|
50
|
+
emit(LLM_DURATION_EVENT, env) { response = @llm.chat(**params(env)) }
|
|
51
|
+
|
|
52
|
+
if (usage = Brute::MessageTransport::LangChain.usage_metrics(response))
|
|
53
|
+
(env[:metadata] ||= {})[:last_llm_usage] = usage
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# langchainrb speaks the OpenAI-style wire format both ways.
|
|
57
|
+
Brute::MessageTransport::LangChain.wrap_each(reply(response)) do |message|
|
|
58
|
+
env[:messages] << message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
emit(AFTER_LLM_EVENT, env)
|
|
62
|
+
env
|
|
63
|
+
rescue => error
|
|
64
|
+
emit(LLM_FAILURE_EVENT, env)
|
|
65
|
+
|
|
66
|
+
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
67
|
+
emit(FARADAY_ERROR_EVENT, env, error)
|
|
68
|
+
else
|
|
69
|
+
emit(STANDARD_ERROR_EVENT, env, error)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
env
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
attr_reader :options
|
|
78
|
+
|
|
79
|
+
def params(env)
|
|
80
|
+
params = {
|
|
81
|
+
messages: Brute::MessageTransport::LangChain.dump_all(env[:messages]),
|
|
82
|
+
temperature: temperature(env),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
tools = tool_definitions(env)
|
|
86
|
+
params[:tools] = tools if tools.any?
|
|
87
|
+
|
|
88
|
+
model = option(env, :model)
|
|
89
|
+
params[:model] = model if model
|
|
90
|
+
|
|
91
|
+
params
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reply(response)
|
|
95
|
+
{
|
|
96
|
+
"role" => "assistant",
|
|
97
|
+
"content" => response.chat_completion,
|
|
98
|
+
"tool_calls" => Array(response.tool_calls),
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tool_definitions(env)
|
|
103
|
+
Brute::Tools::Adapter.wrap_all(option(env, :tools) || []).values.map do |adapter|
|
|
104
|
+
{ type: "function", function: adapter.to_h }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def option(env, key) = options.fetch(key) { env[key] }
|
|
109
|
+
|
|
110
|
+
def temperature(env) = options.fetch(:temperature) { env.fetch(:temperature, DEFAULT_TEMPERATURE) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
__END__
|
|
116
|
+
|
|
117
|
+
describe "brute/completion/lang_chain" do
|
|
118
|
+
require "brute/messages"
|
|
119
|
+
|
|
120
|
+
# langchainrb's own suite fakes the LLM with `receive(:chat)` returning a
|
|
121
|
+
# response that answers chat_completion / tool_calls; this is that shape.
|
|
122
|
+
FakeLangChainResponse = Struct.new(:chat_completion, :tool_calls) unless defined?(FakeLangChainResponse)
|
|
123
|
+
|
|
124
|
+
FakeLangChainLLM = Class.new do
|
|
125
|
+
attr_reader :calls
|
|
126
|
+
|
|
127
|
+
def initialize(response)
|
|
128
|
+
@response = response
|
|
129
|
+
@calls = []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def chat(**params)
|
|
133
|
+
@calls << params
|
|
134
|
+
@response
|
|
135
|
+
end
|
|
136
|
+
end unless defined?(FakeLangChainLLM)
|
|
137
|
+
|
|
138
|
+
it "sends the log as OpenAI-style messages and appends the reply, tool calls included" do
|
|
139
|
+
response = FakeLangChainResponse.new("via langchain", [])
|
|
140
|
+
llm = FakeLangChainLLM.new(response)
|
|
141
|
+
|
|
142
|
+
env = { messages: Brute.log, tools: [], events: [] }
|
|
143
|
+
env[:messages].user("hi")
|
|
144
|
+
|
|
145
|
+
seen = []
|
|
146
|
+
pipeline = Brute::Turn::Pipeline.new
|
|
147
|
+
pipeline.run Brute::Completion::LangChain.new(llm: llm, model: "gpt-4o-mini")
|
|
148
|
+
pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
|
|
149
|
+
pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
|
|
150
|
+
pipeline.call(env)
|
|
151
|
+
|
|
152
|
+
env[:messages].last.role.should == :assistant
|
|
153
|
+
env[:messages].last.content.should == "via langchain"
|
|
154
|
+
seen.should == [:before, :after]
|
|
155
|
+
|
|
156
|
+
llm.calls.first[:model].should == "gpt-4o-mini"
|
|
157
|
+
llm.calls.first[:temperature].should == 0.7
|
|
158
|
+
llm.calls.first[:messages].first[:role].should == "user"
|
|
159
|
+
|
|
160
|
+
# A tool call comes back in the OpenAI shape and lands as a ToolCall.
|
|
161
|
+
calling = FakeLangChainLLM.new(FakeLangChainResponse.new(nil, [
|
|
162
|
+
{ "id" => "tc1", "type" => "function", "function" => { "name" => "echo", "arguments" => { "text" => "hi" } } },
|
|
163
|
+
]))
|
|
164
|
+
tool_env = { messages: Brute.log, tools: [], events: [] }
|
|
165
|
+
tool_env[:messages].user("call it")
|
|
166
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::LangChain.new(llm: calling) }.call(tool_env)
|
|
167
|
+
tool_env[:messages].last.tool_calls.first.name.should == "echo"
|
|
168
|
+
|
|
169
|
+
# A raising provider is reported through the hooks, not up the stack.
|
|
170
|
+
boom = Object.new
|
|
171
|
+
boom.define_singleton_method(:chat) { |**_params| raise "no route to host" }
|
|
172
|
+
failed = []
|
|
173
|
+
failing = Brute::Turn::Pipeline.new
|
|
174
|
+
failing.run Brute::Completion::LangChain.new(llm: boom)
|
|
175
|
+
failing.on(Brute::Hooks::STANDARD_ERROR_EVENT) { |_env, error| failed << error.message }
|
|
176
|
+
|
|
177
|
+
error_env = { messages: Brute.log, tools: [], events: [] }
|
|
178
|
+
error_env[:messages].user("hi")
|
|
179
|
+
failing.call(error_env)
|
|
180
|
+
failed.should == ["no route to host"]
|
|
181
|
+
|
|
182
|
+
# An llm is not optional.
|
|
183
|
+
should.raise(ArgumentError) { Brute::Completion::LangChain.new }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "brute/hooks"
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
module Completion
|
|
9
|
+
# Completion backed by the llm.rb gem (https://github.com/llmrb/llm.rb).
|
|
10
|
+
#
|
|
11
|
+
# Brute.agent
|
|
12
|
+
# .use(Brute::Middleware::SystemPrompt)
|
|
13
|
+
# .run(Brute::Completion::LLMrb.new(
|
|
14
|
+
# provider: :openai,
|
|
15
|
+
# provider_options: { key: ENV["OPENAI_API_KEY"] },
|
|
16
|
+
# model: "gpt-4o-mini",
|
|
17
|
+
# ))
|
|
18
|
+
#
|
|
19
|
+
# # or hand over a provider llm.rb has already built:
|
|
20
|
+
# run Brute::Completion::LLMrb.new(client: LLM.ollama(key: nil), model: "llama3.2:latest")
|
|
21
|
+
#
|
|
22
|
+
# client: an LLM::Provider instance (takes precedence)
|
|
23
|
+
# provider: llm.rb constructor name (:openai, :anthropic,
|
|
24
|
+
# :ollama, ...); falls back to env[:provider]
|
|
25
|
+
# provider_options: kwargs for that constructor, e.g. { key: "..." }
|
|
26
|
+
# model: model id (falls back to env[:model])
|
|
27
|
+
# tools: tools list, any shape Tools::Adapter accepts
|
|
28
|
+
# temperature: sampling temperature (default 0.7)
|
|
29
|
+
class LLMrb
|
|
30
|
+
include Brute::Hooks
|
|
31
|
+
|
|
32
|
+
DEFAULT_TEMPERATURE = 0.7
|
|
33
|
+
|
|
34
|
+
def initialize(**options)
|
|
35
|
+
# Brute depends on no LLM library: the provider gem is required here,
|
|
36
|
+
# at point of use, and only for this completion.
|
|
37
|
+
begin
|
|
38
|
+
require "llm"
|
|
39
|
+
rescue LoadError
|
|
40
|
+
raise LoadError, "#{self.class} needs the 'llm.rb' gem — add `gem \"llm.rb\"` to your Gemfile."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@options = options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(env)
|
|
47
|
+
emit(BEFORE_LLM_EVENT, env)
|
|
48
|
+
|
|
49
|
+
# llm.rb takes the last message as the prompt and the rest as history.
|
|
50
|
+
messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
|
|
51
|
+
prompt = messages.pop
|
|
52
|
+
|
|
53
|
+
response = nil
|
|
54
|
+
emit(LLM_DURATION_EVENT, env) { response = client(env).complete(prompt, **params(env, messages)) }
|
|
55
|
+
|
|
56
|
+
if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
|
|
57
|
+
(env[:metadata] ||= {})[:last_llm_usage] = usage
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Brute::MessageTransport::LLM.wrap_each(response) do |message|
|
|
61
|
+
env[:messages] << message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
emit(AFTER_LLM_EVENT, env)
|
|
65
|
+
env
|
|
66
|
+
rescue => error
|
|
67
|
+
emit(LLM_FAILURE_EVENT, env)
|
|
68
|
+
|
|
69
|
+
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
70
|
+
emit(FARADAY_ERROR_EVENT, env, error)
|
|
71
|
+
else
|
|
72
|
+
emit(STANDARD_ERROR_EVENT, env, error)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
env
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
attr_reader :options
|
|
81
|
+
|
|
82
|
+
def params(env, history)
|
|
83
|
+
params = {
|
|
84
|
+
role: nil,
|
|
85
|
+
model: option(env, :model),
|
|
86
|
+
messages: history,
|
|
87
|
+
temperature: temperature(env),
|
|
88
|
+
}.compact
|
|
89
|
+
|
|
90
|
+
functions = functions(env)
|
|
91
|
+
params[:tools] = functions if functions.any?
|
|
92
|
+
params
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def client(env)
|
|
96
|
+
options[:client] || begin
|
|
97
|
+
provider = option(env, :provider) or
|
|
98
|
+
raise ArgumentError, "#{self.class} needs a client: or provider: option"
|
|
99
|
+
|
|
100
|
+
::LLM.public_send(provider, **(options[:provider_options] || {}))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Brute tools as llm.rb functions, executed by llm.rb's own dispatch.
|
|
105
|
+
def functions(env)
|
|
106
|
+
Brute::Tools::Adapter.wrap_all(option(env, :tools) || []).values.map do |adapter|
|
|
107
|
+
schema = adapter.to_h[:parameters]
|
|
108
|
+
|
|
109
|
+
::LLM.function(adapter.name) do |fn|
|
|
110
|
+
fn.description adapter.description
|
|
111
|
+
fn.params { schema }
|
|
112
|
+
fn.define { |**args| adapter.call(args) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def option(env, key) = options.fetch(key) { env[key] }
|
|
118
|
+
|
|
119
|
+
def temperature(env) = options.fetch(:temperature) { env.fetch(:temperature, DEFAULT_TEMPERATURE) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
__END__
|
|
125
|
+
|
|
126
|
+
describe "brute/completion/llmrb" do
|
|
127
|
+
require "brute/messages"
|
|
128
|
+
|
|
129
|
+
# llm.rb's own suite drives a real provider and stubs the HTTP; the seam it
|
|
130
|
+
# leaves for a caller is the provider object's #complete, which is what a
|
|
131
|
+
# client: option replaces.
|
|
132
|
+
FakeLLMrbClient = Class.new do
|
|
133
|
+
attr_reader :calls
|
|
134
|
+
|
|
135
|
+
def initialize(content = "via llm.rb")
|
|
136
|
+
@content = content
|
|
137
|
+
@calls = []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def complete(prompt, **params)
|
|
141
|
+
@calls << { prompt: prompt, params: params }
|
|
142
|
+
::LLM::Message.new(:assistant, @content)
|
|
143
|
+
end
|
|
144
|
+
end unless defined?(FakeLLMrbClient)
|
|
145
|
+
|
|
146
|
+
it "sends the last message as the prompt, the rest as history, and appends the reply" do
|
|
147
|
+
client = FakeLLMrbClient.new("hello from llm.rb")
|
|
148
|
+
|
|
149
|
+
env = { messages: Brute.log, provider: :stub, model: "env-model", tools: [], events: [] }
|
|
150
|
+
env[:messages].user("first")
|
|
151
|
+
env[:messages] << Brute::Message.new(role: :assistant, content: "earlier")
|
|
152
|
+
env[:messages].user("latest")
|
|
153
|
+
|
|
154
|
+
seen = []
|
|
155
|
+
pipeline = Brute::Turn::Pipeline.new
|
|
156
|
+
pipeline.run Brute::Completion::LLMrb.new(client: client, temperature: 0.1)
|
|
157
|
+
pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
|
|
158
|
+
pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
|
|
159
|
+
pipeline.call(env)
|
|
160
|
+
|
|
161
|
+
env[:messages].last.role.should == :assistant
|
|
162
|
+
env[:messages].last.content.should == "hello from llm.rb"
|
|
163
|
+
seen.should == [:before, :after]
|
|
164
|
+
|
|
165
|
+
call = client.calls.first
|
|
166
|
+
call[:prompt].content.should == "latest"
|
|
167
|
+
call[:params][:messages].size.should == 2
|
|
168
|
+
call[:params][:temperature].should == 0.1
|
|
169
|
+
call[:params][:model].should == "env-model" # falls back to env
|
|
170
|
+
|
|
171
|
+
# Without a client, a provider is required to build one.
|
|
172
|
+
no_provider = Brute::Turn::Pipeline.new
|
|
173
|
+
no_provider.run Brute::Completion::LLMrb.new
|
|
174
|
+
failed = []
|
|
175
|
+
no_provider.on(Brute::Hooks::STANDARD_ERROR_EVENT) { |_env, error| failed << error.class }
|
|
176
|
+
|
|
177
|
+
bare = { messages: Brute.log, tools: [], events: [] }
|
|
178
|
+
bare[:messages].user("hi")
|
|
179
|
+
no_provider.call(bare)
|
|
180
|
+
failed.should == [ArgumentError]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -13,29 +13,45 @@ module Brute
|
|
|
13
13
|
# its own provider, and only when you use it.
|
|
14
14
|
module Completion
|
|
15
15
|
class OpenRouter
|
|
16
|
+
include Brute::Hooks
|
|
17
|
+
|
|
16
18
|
# config: keyword arguments for OpenRouter::Client.new
|
|
17
19
|
# (access_token:, request_timeout:, uri_base:, extra_headers:).
|
|
18
20
|
# Defaults to OpenRouter.configuration's global settings.
|
|
19
21
|
# options: keyword arguments for OpenRouter::CompletionOptions.new
|
|
20
22
|
# (model:, temperature:, tools:, ...).
|
|
21
23
|
def initialize(config: {}, **options)
|
|
24
|
+
# Brute depends on no LLM library: the provider gem is required here,
|
|
25
|
+
# at point of use, and only for this completion.
|
|
26
|
+
begin
|
|
27
|
+
require "open_router"
|
|
28
|
+
rescue LoadError
|
|
29
|
+
raise LoadError, "#{self.class} needs the 'open_router_enhanced' gem — add `gem \"open_router_enhanced\"` to your Gemfile."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Brute::Completion.async_faraday!
|
|
33
|
+
|
|
22
34
|
@config = config
|
|
23
35
|
@options = ::OpenRouter::CompletionOptions.new(**options)
|
|
24
36
|
end
|
|
25
37
|
|
|
26
38
|
def call(env)
|
|
27
|
-
|
|
39
|
+
emit(BEFORE_LLM_EVENT, env)
|
|
28
40
|
|
|
29
41
|
messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
|
|
30
42
|
|
|
31
43
|
::OpenRouter::Client.new(**@config).then do |client|
|
|
32
|
-
|
|
44
|
+
response = nil
|
|
45
|
+
emit(LLM_DURATION_EVENT, env) { response = client.complete(messages, @options) }
|
|
46
|
+
|
|
47
|
+
response.then do |response|
|
|
33
48
|
|
|
34
49
|
# Expose the provider's usage for downstream accounting
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
# (goal budgets, autonomous limits, compaction thresholds, usage
|
|
51
|
+
# attribution) — additive metadata only, normalised so a reader
|
|
52
|
+
# does not have to know which provider answered.
|
|
53
|
+
if (usage = Brute::MessageTransport::OpenRouter.usage_metrics(response))
|
|
54
|
+
(env[:metadata] ||= {})[:last_llm_usage] = usage
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
# OpenRouter in fact only returns a single message...
|
|
@@ -46,23 +62,23 @@ module Brute
|
|
|
46
62
|
end
|
|
47
63
|
end
|
|
48
64
|
|
|
49
|
-
|
|
65
|
+
emit(AFTER_LLM_EVENT, env)
|
|
50
66
|
env
|
|
51
67
|
# A provider call that raises is reported through the hooks rather than
|
|
52
68
|
# up the stack: :llm_failure with the turn env, then one hook naming the
|
|
53
69
|
# kind of failure. The classes are looked up defensively — the provider
|
|
54
70
|
# gem is only a dependency of the app that uses this middleware.
|
|
55
71
|
rescue => error
|
|
56
|
-
|
|
72
|
+
emit(LLM_FAILURE_EVENT, env)
|
|
57
73
|
|
|
58
74
|
if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
|
|
59
|
-
|
|
75
|
+
emit(FARADAY_ERROR_EVENT, env, error)
|
|
60
76
|
|
|
61
77
|
elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
|
|
62
|
-
|
|
78
|
+
emit(OPEN_ROUTER_SERVER_ERROR_EVENT, env, error)
|
|
63
79
|
|
|
64
80
|
else
|
|
65
|
-
|
|
81
|
+
emit(STANDARD_ERROR_EVENT, env, error)
|
|
66
82
|
|
|
67
83
|
end
|
|
68
84
|
|
|
@@ -77,25 +93,23 @@ __END__
|
|
|
77
93
|
describe "brute/completion/open_router" do
|
|
78
94
|
require "brute/messages"
|
|
79
95
|
|
|
80
|
-
# The repo suite has no open_router gem; stub the two constants the
|
|
81
|
-
# middleware touches (the transport wraps duck-typed responses fine).
|
|
82
|
-
begin
|
|
83
|
-
require "open_router"
|
|
84
|
-
rescue LoadError
|
|
85
|
-
module OpenRouter
|
|
86
|
-
CompletionOptions = Class.new { def initialize(**_opts); end }
|
|
87
|
-
Client = Class.new
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
96
|
FakeUsageResponse = Struct.new(:usage) do
|
|
92
97
|
def choices
|
|
93
98
|
[{ "message" => { "role" => "assistant", "content" => "hello" } }]
|
|
94
99
|
end
|
|
95
100
|
end unless defined?(FakeUsageResponse)
|
|
96
101
|
|
|
102
|
+
# A completion only gets its emit from the pipeline that runs it, so every
|
|
103
|
+
# turn here goes through a builder rather than calling the object directly.
|
|
104
|
+
running = lambda do |completion, &block|
|
|
105
|
+
pipeline = Brute::Turn::Pipeline.new
|
|
106
|
+
pipeline.run completion
|
|
107
|
+
block&.call(pipeline)
|
|
108
|
+
pipeline
|
|
109
|
+
end
|
|
110
|
+
|
|
97
111
|
# Run one turn against a stubbed OpenRouter::Client and hand back the env.
|
|
98
|
-
with_fake_client = lambda do |
|
|
112
|
+
with_fake_client = lambda do |completion, response|
|
|
99
113
|
fake_client = Object.new
|
|
100
114
|
fake_client.define_singleton_method(:complete) { |_messages, _options| response }
|
|
101
115
|
original = OpenRouter::Client.method(:new)
|
|
@@ -103,7 +117,7 @@ describe "brute/completion/open_router" do
|
|
|
103
117
|
begin
|
|
104
118
|
env = { messages: Brute.log }
|
|
105
119
|
env[:messages].user("hi")
|
|
106
|
-
|
|
120
|
+
running.call(completion).call(env)
|
|
107
121
|
env
|
|
108
122
|
ensure
|
|
109
123
|
OpenRouter::Client.define_singleton_method(:new, original)
|
|
@@ -112,24 +126,23 @@ describe "brute/completion/open_router" do
|
|
|
112
126
|
|
|
113
127
|
it "records the provider usage into env metadata and appends the message" do
|
|
114
128
|
response = FakeUsageResponse.new({ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15 })
|
|
115
|
-
env = with_fake_client.call(Brute::Completion::OpenRouter.new
|
|
129
|
+
env = with_fake_client.call(Brute::Completion::OpenRouter.new, response)
|
|
116
130
|
|
|
117
131
|
env[:messages].last.role.should == :assistant
|
|
118
|
-
|
|
132
|
+
# Normalised, not the provider's raw hash.
|
|
133
|
+
env[:metadata][:last_llm_usage].total.should == 15
|
|
134
|
+
env[:metadata][:last_llm_usage].input.should == 10
|
|
135
|
+
env[:metadata][:last_llm_usage].output.should == 5
|
|
119
136
|
end
|
|
120
137
|
|
|
121
138
|
it "leaves metadata alone when the response has no usage" do
|
|
122
|
-
env = with_fake_client.call(Brute::Completion::OpenRouter.new
|
|
139
|
+
env = with_fake_client.call(Brute::Completion::OpenRouter.new, FakeUsageResponse.new(nil))
|
|
123
140
|
|
|
124
141
|
env.key?(:metadata).should.be.false
|
|
125
142
|
end
|
|
126
143
|
|
|
127
144
|
it "reports a failed provider call through the hooks instead of raising" do
|
|
128
145
|
seen = []
|
|
129
|
-
hooks = Brute::Hooks.new
|
|
130
|
-
%i[llm_failure standard_error after_llm].each do |event|
|
|
131
|
-
hooks.on(event) { |payload| seen << [event, payload] }
|
|
132
|
-
end
|
|
133
146
|
|
|
134
147
|
boom = RuntimeError.new("no route to host")
|
|
135
148
|
fake_client = Object.new
|
|
@@ -138,24 +151,23 @@ describe "brute/completion/open_router" do
|
|
|
138
151
|
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
139
152
|
|
|
140
153
|
begin
|
|
141
|
-
env = { messages: Brute.log
|
|
154
|
+
env = { messages: Brute.log }
|
|
142
155
|
env[:messages].user("hi")
|
|
143
|
-
|
|
156
|
+
pipeline = Brute::Turn::Pipeline.new
|
|
157
|
+
pipeline.run Brute::Completion::OpenRouter.new
|
|
158
|
+
%i[llm_failure standard_error after_llm].each do |event|
|
|
159
|
+
pipeline.on(event) { |hook_env, extra| seen << [event, hook_env, extra] }
|
|
160
|
+
end
|
|
161
|
+
returned = pipeline.call(env)
|
|
144
162
|
|
|
145
163
|
returned.should.be.identical_to env
|
|
146
164
|
seen.map(&:first).should == [:llm_failure, :standard_error]
|
|
147
|
-
seen.first.
|
|
148
|
-
seen.last.
|
|
165
|
+
seen.first[1].should.be.identical_to env
|
|
166
|
+
seen.last[1].should.be.identical_to env
|
|
167
|
+
seen.last[2].should.be.identical_to boom
|
|
149
168
|
ensure
|
|
150
169
|
OpenRouter::Client.define_singleton_method(:new, original)
|
|
151
170
|
end
|
|
152
171
|
end
|
|
153
172
|
|
|
154
|
-
it "still answers to the deprecated Middleware::OpenRouter::Completion name" do
|
|
155
|
-
deprecated = Brute::Middleware::OpenRouter::Completion.new(->(e) { e })
|
|
156
|
-
deprecated.should.be.kind_of?(Brute::Completion::OpenRouter)
|
|
157
|
-
|
|
158
|
-
env = with_fake_client.call(deprecated, FakeUsageResponse.new(nil))
|
|
159
|
-
env[:messages].last.content.should == "hello"
|
|
160
|
-
end
|
|
161
173
|
end
|