brute 0.4.0 → 1.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +24 -0
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +70 -23
  11. data/lib/brute/middleware/doom_loop_detection.rb +110 -7
  12. data/lib/brute/middleware/llm_call.rb +88 -1
  13. data/lib/brute/middleware/message_tracking.rb +140 -10
  14. data/lib/brute/middleware/otel/span.rb +32 -2
  15. data/lib/brute/middleware/otel/token_usage.rb +38 -0
  16. data/lib/brute/middleware/otel/tool_calls.rb +30 -1
  17. data/lib/brute/middleware/otel/tool_results.rb +29 -1
  18. data/lib/brute/middleware/otel.rb +5 -0
  19. data/lib/brute/middleware/reasoning_normalizer.rb +94 -0
  20. data/lib/brute/middleware/retry.rb +113 -1
  21. data/lib/brute/middleware/session_persistence.rb +46 -3
  22. data/lib/brute/middleware/token_tracking.rb +78 -0
  23. data/lib/brute/middleware/tool_error_tracking.rb +128 -1
  24. data/lib/brute/middleware/tool_use_guard.rb +64 -28
  25. data/lib/brute/middleware/tracing.rb +63 -2
  26. data/lib/brute/middleware.rb +18 -0
  27. data/lib/brute/orchestrator/turn.rb +105 -0
  28. data/lib/brute/patches/buffer_nil_guard.rb +5 -0
  29. data/lib/brute/pipeline.rb +86 -7
  30. data/lib/brute/prompts/build_switch.rb +29 -0
  31. data/lib/brute/prompts/environment.rb +43 -0
  32. data/lib/brute/prompts/identity.rb +29 -0
  33. data/lib/brute/prompts/instructions.rb +21 -0
  34. data/lib/brute/prompts/max_steps.rb +25 -0
  35. data/lib/brute/prompts/plan_reminder.rb +25 -0
  36. data/lib/brute/prompts/skills.rb +13 -0
  37. data/lib/brute/prompts.rb +28 -0
  38. data/lib/brute/providers/ollama.rb +135 -0
  39. data/lib/brute/providers/opencode_go.rb +5 -0
  40. data/lib/brute/providers/opencode_zen.rb +7 -2
  41. data/lib/brute/providers/shell.rb +2 -2
  42. data/lib/brute/providers/shell_response.rb +7 -2
  43. data/lib/brute/providers.rb +62 -0
  44. data/lib/brute/queue/base_queue.rb +222 -0
  45. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  46. data/lib/brute/queue/parallel_queue.rb +66 -0
  47. data/lib/brute/queue/sequential_queue.rb +63 -0
  48. data/lib/brute/{message_store.rb → store/message_store.rb} +155 -62
  49. data/lib/brute/store/session.rb +106 -0
  50. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  51. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  52. data/lib/brute/system_prompt.rb +101 -0
  53. data/lib/brute/tools/delegate.rb +59 -0
  54. data/lib/brute/tools/fs_patch.rb +54 -2
  55. data/lib/brute/tools/fs_read.rb +5 -0
  56. data/lib/brute/tools/fs_remove.rb +7 -2
  57. data/lib/brute/tools/fs_search.rb +5 -0
  58. data/lib/brute/tools/fs_undo.rb +7 -2
  59. data/lib/brute/tools/fs_write.rb +40 -2
  60. data/lib/brute/tools/net_fetch.rb +5 -0
  61. data/lib/brute/tools/question.rb +5 -0
  62. data/lib/brute/tools/shell.rb +5 -0
  63. data/lib/brute/tools/todo_read.rb +6 -1
  64. data/lib/brute/tools/todo_write.rb +6 -1
  65. data/lib/brute/tools.rb +31 -0
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +40 -204
  68. metadata +31 -20
  69. data/lib/brute/agent_stream.rb +0 -63
  70. data/lib/brute/hooks.rb +0 -84
  71. data/lib/brute/orchestrator.rb +0 -391
  72. data/lib/brute/session.rb +0 -161
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
5
+
3
6
  module Brute
4
7
  module Tools
5
8
  class FSPatch < LLM::Tool
@@ -14,13 +17,13 @@ module Brute
14
17
 
15
18
  def call(file_path:, old_string:, new_string:, replace_all: false)
16
19
  path = File.expand_path(file_path)
17
- Brute::FileMutationQueue.serialize(path) do
20
+ Brute::Queue::FileMutationQueue.serialize(path) do
18
21
  raise "File not found: #{path}" unless File.exist?(path)
19
22
 
20
23
  original = File.read(path)
21
24
  raise "old_string not found in #{path}" unless original.include?(old_string)
22
25
 
23
- Brute::SnapshotStore.save(path)
26
+ Brute::Store::SnapshotStore.save(path)
24
27
 
25
28
  updated = if replace_all
26
29
  original.gsub(old_string, new_string)
@@ -37,3 +40,52 @@ module Brute
37
40
  end
38
41
  end
39
42
  end
43
+
44
+ test do
45
+ require "tmpdir"
46
+
47
+ it "replaces old_string with new_string" do
48
+ Dir.mktmpdir do |dir|
49
+ path = File.join(dir, "test.rb")
50
+ File.write(path, "hello world\n")
51
+ result = Brute::Tools::FSPatch.new.call(file_path: path, old_string: "world", new_string: "ruby")
52
+ File.read(path).should == "hello ruby\n"
53
+ end
54
+ end
55
+
56
+ it "returns a unified diff" do
57
+ Dir.mktmpdir do |dir|
58
+ path = File.join(dir, "test.rb")
59
+ File.write(path, "line1\nold line\nline3\n")
60
+ result = Brute::Tools::FSPatch.new.call(file_path: path, old_string: "old line", new_string: "new line")
61
+ result[:diff].should =~ /\-old line/
62
+ end
63
+ end
64
+
65
+ it "raises when file not found" do
66
+ Dir.mktmpdir do |dir|
67
+ lambda {
68
+ Brute::Tools::FSPatch.new.call(file_path: File.join(dir, "nope.rb"), old_string: "a", new_string: "b")
69
+ }.should.raise(RuntimeError)
70
+ end
71
+ end
72
+
73
+ it "raises when old_string not found" do
74
+ Dir.mktmpdir do |dir|
75
+ path = File.join(dir, "test.rb")
76
+ File.write(path, "hello\n")
77
+ lambda {
78
+ Brute::Tools::FSPatch.new.call(file_path: path, old_string: "missing", new_string: "new")
79
+ }.should.raise(RuntimeError)
80
+ end
81
+ end
82
+
83
+ it "supports replace_all" do
84
+ Dir.mktmpdir do |dir|
85
+ path = File.join(dir, "test.rb")
86
+ File.write(path, "aaa bbb aaa\n")
87
+ result = Brute::Tools::FSPatch.new.call(file_path: path, old_string: "aaa", new_string: "ccc", replace_all: true)
88
+ result[:replacements].should == 2
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class FSRead < LLM::Tool
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "fileutils"
4
9
 
5
10
  module Brute
@@ -12,10 +17,10 @@ module Brute
12
17
 
13
18
  def call(path:)
14
19
  target = File.expand_path(path)
15
- Brute::FileMutationQueue.serialize(target) do
20
+ Brute::Queue::FileMutationQueue.serialize(target) do
16
21
  raise "Path not found: #{target}" unless File.exist?(target)
17
22
 
18
- Brute::SnapshotStore.save(target) if File.file?(target)
23
+ Brute::Store::SnapshotStore.save(target) if File.file?(target)
19
24
 
20
25
  if File.directory?(target)
21
26
  Dir.rmdir(target)
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "open3"
4
9
 
5
10
  module Brute
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class FSUndo < LLM::Tool
@@ -11,8 +16,8 @@ module Brute
11
16
 
12
17
  def call(path:)
13
18
  target = File.expand_path(path)
14
- Brute::FileMutationQueue.serialize(target) do
15
- snapshot = Brute::SnapshotStore.pop(target)
19
+ Brute::Queue::FileMutationQueue.serialize(target) do
20
+ snapshot = Brute::Store::SnapshotStore.pop(target)
16
21
  raise "No undo history available for: #{target}" unless snapshot
17
22
 
18
23
  if snapshot == :did_not_exist
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "brute"
3
5
  require 'fileutils'
4
6
 
5
7
  module Brute
@@ -14,9 +16,9 @@ module Brute
14
16
 
15
17
  def call(file_path:, content:)
16
18
  path = File.expand_path(file_path)
17
- Brute::FileMutationQueue.serialize(path) do
19
+ Brute::Queue::FileMutationQueue.serialize(path) do
18
20
  old_content = File.exist?(path) ? File.read(path) : ''
19
- Brute::SnapshotStore.save(path)
21
+ Brute::Store::SnapshotStore.save(path)
20
22
  FileUtils.mkdir_p(File.dirname(path))
21
23
  File.write(path, content)
22
24
  diff = Brute::Diff.unified(old_content, content)
@@ -26,3 +28,39 @@ module Brute
26
28
  end
27
29
  end
28
30
  end
31
+
32
+ test do
33
+ require "tmpdir"
34
+
35
+ it "writes content to a new file" do
36
+ Dir.mktmpdir do |dir|
37
+ path = File.join(dir, "new.rb")
38
+ Brute::Tools::FSWrite.new.call(file_path: path, content: "hello\n")
39
+ File.read(path).should == "hello\n"
40
+ end
41
+ end
42
+
43
+ it "returns a diff for new files" do
44
+ Dir.mktmpdir do |dir|
45
+ path = File.join(dir, "new.rb")
46
+ result = Brute::Tools::FSWrite.new.call(file_path: path, content: "line1\nline2\n")
47
+ result[:diff].should =~ /\+line1/
48
+ end
49
+ end
50
+
51
+ it "creates parent directories" do
52
+ Dir.mktmpdir do |dir|
53
+ path = File.join(dir, "deep", "nested", "file.rb")
54
+ result = Brute::Tools::FSWrite.new.call(file_path: path, content: "nested\n")
55
+ result[:success].should.be.true
56
+ end
57
+ end
58
+
59
+ it "returns byte count" do
60
+ Dir.mktmpdir do |dir|
61
+ path = File.join(dir, "test.rb")
62
+ result = Brute::Tools::FSWrite.new.call(file_path: path, content: "hello")
63
+ result[:bytes].should == 5
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "net/http"
4
9
  require "uri"
5
10
 
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class Question < LLM::Tool
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  require "open3"
4
9
 
5
10
  module Brute
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class TodoRead < LLM::Tool
@@ -8,7 +13,7 @@ module Brute
8
13
  param :_placeholder, String, "Unused, pass any value"
9
14
 
10
15
  def call(_placeholder: nil)
11
- {todos: Brute::TodoStore.all}
16
+ {todos: Brute::Store::TodoStore.all}
12
17
  end
13
18
  end
14
19
  end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ if __FILE__ == $0
4
+ require "bundler/setup"
5
+ require "brute"
6
+ end
7
+
3
8
  module Brute
4
9
  module Tools
5
10
  class TodoWrite < LLM::Tool
@@ -24,7 +29,7 @@ module Brute
24
29
  t = t.transform_keys(&:to_sym) if t.is_a?(Hash)
25
30
  {id: t[:id], content: t[:content], status: t[:status]}
26
31
  end
27
- Brute::TodoStore.replace(items)
32
+ Brute::Store::TodoStore.replace(items)
28
33
  {success: true, count: items.size}
29
34
  end
30
35
  end
@@ -0,0 +1,31 @@
1
+ require_relative 'tools/fs_read'
2
+ require_relative 'tools/fs_write'
3
+ require_relative 'tools/fs_patch'
4
+ require_relative 'tools/fs_remove'
5
+ require_relative 'tools/fs_search'
6
+ require_relative 'tools/fs_undo'
7
+ require_relative 'tools/shell'
8
+ require_relative 'tools/net_fetch'
9
+ require_relative 'tools/todo_write'
10
+ require_relative 'tools/todo_read'
11
+ require_relative 'tools/delegate'
12
+ require_relative 'tools/question'
13
+
14
+ module Brute
15
+ module Tools
16
+ ALL = [
17
+ Tools::FSRead,
18
+ Tools::FSWrite,
19
+ Tools::FSPatch,
20
+ Tools::FSRemove,
21
+ Tools::FSSearch,
22
+ Tools::FSUndo,
23
+ Tools::Shell,
24
+ Tools::NetFetch,
25
+ Tools::TodoWrite,
26
+ Tools::TodoRead,
27
+ Tools::Delegate,
28
+ Tools::Question
29
+ ].freeze
30
+ end
31
+ end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "0.4.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/brute.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'llm'
4
4
  require 'timeout'
5
5
  require 'logger'
6
+ require 'scampi/kernel_ext'
6
7
 
7
8
  # Brute — a coding agent built on llm.rb
8
9
  #
@@ -11,220 +12,55 @@ require 'logger'
11
12
  #
12
13
  # Tracing → Retry → Session → Tokens → Compaction → ToolErrors → DoomLoop → Reasoning → [LLM Call]
13
14
  #
15
+ # Entry point:
16
+ #
17
+ # agent = Brute::Agent.new(provider:, model:, tools:, system_prompt:)
18
+ # step = Brute::Loop::AgentTurn.perform(agent:, session:, pipeline:, input:)
19
+ #
14
20
  require_relative 'brute/version'
15
21
 
16
22
  module Brute
17
- module Tools; end
18
- module Hooks; end
19
- module Middleware; end
23
+ def self.provider
24
+ @provider ||= Brute::Providers.guess_from_env
25
+ end
26
+
27
+ def self.provider=(p)
28
+ @provider = p
29
+ end
20
30
  end
21
31
 
22
- # Infrastructure
23
32
  require_relative 'brute/diff'
24
- require_relative 'brute/snapshot_store'
25
- require_relative 'brute/todo_store'
26
- require_relative 'brute/file_mutation_queue'
27
- require_relative 'brute/doom_loop'
28
- require_relative 'brute/hooks'
29
- require_relative 'brute/compactor'
30
- require_relative 'brute/prompts/base'
31
- require_relative 'brute/prompts/identity'
32
- require_relative 'brute/prompts/tone_and_style'
33
- require_relative 'brute/prompts/objectivity'
34
- require_relative 'brute/prompts/task_management'
35
- require_relative 'brute/prompts/doing_tasks'
36
- require_relative 'brute/prompts/tool_usage'
37
- require_relative 'brute/prompts/conventions'
38
- require_relative 'brute/prompts/git_safety'
39
- require_relative 'brute/prompts/code_references'
40
- require_relative 'brute/prompts/environment'
41
- require_relative 'brute/prompts/instructions'
42
- require_relative 'brute/prompts/editing_approach'
43
- require_relative 'brute/prompts/autonomy'
44
- require_relative 'brute/prompts/editing_constraints'
45
- require_relative 'brute/prompts/frontend_tasks'
46
- require_relative 'brute/prompts/proactiveness'
47
- require_relative 'brute/prompts/code_style'
48
- require_relative 'brute/prompts/security_and_safety'
49
- require_relative 'brute/prompts/skills'
50
- require_relative 'brute/prompts/plan_reminder'
51
- require_relative 'brute/prompts/max_steps'
52
- require_relative 'brute/prompts/build_switch'
53
33
  require_relative 'brute/skill'
34
+ require_relative 'brute/prompts'
54
35
  require_relative 'brute/system_prompt'
55
- require_relative 'brute/message_store'
56
- require_relative 'brute/session'
57
36
  require_relative 'brute/pipeline'
58
- require_relative 'brute/agent_stream'
37
+ require_relative 'brute/agent'
38
+
39
+ # Brute::Store
40
+ require_relative 'brute/store/snapshot_store'
41
+ require_relative 'brute/store/todo_store'
42
+ require_relative 'brute/store/message_store'
43
+ require_relative 'brute/store/session'
44
+
45
+ # Brute::Loop (before Queue — queue tests reference Loop::Step)
46
+ require_relative 'brute/loop/doom_loop'
47
+ require_relative 'brute/loop/compactor'
48
+ require_relative 'brute/loop/agent_stream'
49
+ require_relative 'brute/loop/step'
50
+ require_relative 'brute/loop/tool_call_step'
51
+
52
+ # Brute::Queue
53
+ require_relative 'brute/queue/file_mutation_queue'
54
+ require_relative 'brute/queue/base_queue'
55
+ require_relative 'brute/queue/sequential_queue'
56
+ require_relative 'brute/queue/parallel_queue'
57
+
58
+ # Brute::Loop (agent_turn depends on Queue)
59
+ require_relative 'brute/loop/agent_turn'
59
60
 
60
- # Provider patches
61
61
  require_relative 'brute/patches/anthropic_tool_role'
62
62
  require_relative 'brute/patches/buffer_nil_guard'
63
63
 
64
- # Middleware (Rack-style)
65
- require_relative 'brute/middleware/base'
66
- require_relative 'brute/middleware/llm_call'
67
- require_relative 'brute/middleware/retry'
68
- require_relative 'brute/middleware/doom_loop_detection'
69
- require_relative 'brute/middleware/token_tracking'
70
- require_relative 'brute/middleware/compaction_check'
71
- require_relative 'brute/middleware/session_persistence'
72
- require_relative 'brute/middleware/message_tracking'
73
- require_relative 'brute/middleware/tracing'
74
- require_relative 'brute/middleware/tool_error_tracking'
75
- require_relative 'brute/middleware/reasoning_normalizer'
76
- require_relative "brute/middleware/tool_use_guard"
77
- require_relative "brute/middleware/otel"
78
-
79
- # Tools
80
- require_relative 'brute/tools/fs_read'
81
- require_relative 'brute/tools/fs_write'
82
- require_relative 'brute/tools/fs_patch'
83
- require_relative 'brute/tools/fs_remove'
84
- require_relative 'brute/tools/fs_search'
85
- require_relative 'brute/tools/fs_undo'
86
- require_relative 'brute/tools/shell'
87
- require_relative 'brute/tools/net_fetch'
88
- require_relative 'brute/tools/todo_write'
89
- require_relative 'brute/tools/todo_read'
90
- require_relative 'brute/tools/delegate'
91
- require_relative 'brute/tools/question'
92
-
93
- # Providers
94
- require_relative 'brute/providers/shell_response'
95
- require_relative 'brute/providers/shell'
96
- require_relative 'brute/providers/models_dev'
97
- require_relative 'brute/providers/opencode_zen'
98
- require_relative 'brute/providers/opencode_go'
99
-
100
- # Orchestrator (depends on tools, middleware, and infrastructure)
101
- require_relative 'brute/orchestrator'
102
-
103
- module Brute
104
- # The complete set of tools available to the agent.
105
- TOOLS = [
106
- Tools::FSRead,
107
- Tools::FSWrite,
108
- Tools::FSPatch,
109
- Tools::FSRemove,
110
- Tools::FSSearch,
111
- Tools::FSUndo,
112
- Tools::Shell,
113
- Tools::NetFetch,
114
- Tools::TodoWrite,
115
- Tools::TodoRead,
116
- Tools::Delegate,
117
- Tools::Question
118
- ].freeze
119
-
120
- # Default provider, resolved from environment.
121
- def self.provider
122
- @provider ||= resolve_provider
123
- end
124
-
125
- def self.provider=(p)
126
- @provider = p
127
- end
128
-
129
- # Create a new orchestrator with sensible defaults.
130
- def self.agent(cwd: Dir.pwd, model: nil, tools: TOOLS, session: nil, reasoning: {}, agent_name: nil, **callbacks)
131
- Orchestrator.new(
132
- provider: provider,
133
- model: model,
134
- tools: tools,
135
- cwd: cwd,
136
- session: session,
137
- reasoning: reasoning,
138
- agent_name: agent_name,
139
- **callbacks
140
- )
141
- end
142
-
143
- PROVIDERS = {
144
- 'anthropic' => ->(key) { LLM.anthropic(key: key).tap { Patches::AnthropicToolRole.apply! } },
145
- 'openai' => ->(key) { LLM.openai(key: key) },
146
- 'google' => ->(key) { LLM.google(key: key) },
147
- 'deepseek' => ->(key) { LLM.deepseek(key: key) },
148
- 'ollama' => ->(key) { LLM.ollama(key: key) },
149
- 'xai' => ->(key) { LLM.xai(key: key) },
150
- 'opencode_zen' => ->(key) { LLM::OpencodeZen.new(key: key) },
151
- 'opencode_go' => ->(key) { LLM::OpencodeGo.new(key: key) },
152
- 'shell' => ->(_key) { Providers::Shell.new },
153
- }.freeze
154
-
155
- # List provider names that have API keys configured in the environment.
156
- # The shell provider is always available (no key needed).
157
- def self.configured_providers
158
- PROVIDERS.keys.select { |name| api_key_for(name) }
159
- end
160
-
161
- # Build a provider instance for the given name using available API keys.
162
- # Returns nil if no key is found.
163
- def self.provider_for(name)
164
- key = api_key_for(name)
165
- return nil unless key
166
-
167
- factory = PROVIDERS[name]
168
- return nil unless factory
169
-
170
- factory.call(key)
171
- end
172
-
173
- # Look up the API key for a given provider name.
174
- def self.api_key_for(name)
175
- # Shell provider needs no key.
176
- return "none" if name == "shell"
177
-
178
- # OpenCode providers: check OPENCODE_API_KEY, fall back to "public" for anonymous access.
179
- if name == "opencode_zen" || name == "opencode_go"
180
- return ENV["OPENCODE_API_KEY"] || "public"
181
- end
182
-
183
- # Explicit generic key always works
184
- return ENV["LLM_API_KEY"] if ENV["LLM_API_KEY"]
185
-
186
- case name
187
- when "anthropic" then ENV["ANTHROPIC_API_KEY"]
188
- when "openai" then ENV["OPENAI_API_KEY"]
189
- when "google" then ENV["GOOGLE_API_KEY"]
190
- end
191
- end
192
-
193
- # Resolve the LLM provider from environment variables.
194
- #
195
- # Checks in order:
196
- # 1. LLM_API_KEY + LLM_PROVIDER (explicit)
197
- # 2. ANTHROPIC_API_KEY (implicit: provider = anthropic)
198
- # 3. OPENAI_API_KEY (implicit: provider = openai)
199
- # 4. GOOGLE_API_KEY (implicit: provider = google)
200
- # 5. OPENCODE_API_KEY (implicit: provider = opencode_zen)
201
- #
202
- # Returns nil if no key is found. Error is deferred to Orchestrator#run.
203
- def self.resolve_provider
204
- if ENV['LLM_API_KEY']
205
- key = ENV['LLM_API_KEY']
206
- name = ENV.fetch('LLM_PROVIDER', 'anthropic').downcase
207
- elsif ENV['ANTHROPIC_API_KEY']
208
- key = ENV['ANTHROPIC_API_KEY']
209
- name = 'anthropic'
210
- elsif ENV['OPENAI_API_KEY']
211
- key = ENV['OPENAI_API_KEY']
212
- name = 'openai'
213
- elsif ENV['GOOGLE_API_KEY']
214
- key = ENV['GOOGLE_API_KEY']
215
- name = 'google'
216
- elsif ENV['OPENCODE_API_KEY']
217
- key = ENV['OPENCODE_API_KEY']
218
- name = 'opencode_zen'
219
- else
220
- return nil
221
- end
222
-
223
- factory = PROVIDERS[name]
224
- raise "Unknown LLM provider: #{name}. Available: #{PROVIDERS.keys.join(', ')}" unless factory
225
-
226
- factory.call(key)
227
- end
228
-
229
- private_class_method :resolve_provider
230
- end
64
+ require_relative 'brute/middleware'
65
+ require_relative 'brute/tools'
66
+ require_relative 'brute/providers'