ruby-mana 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66fae9938dbde50412ea78e15f01724b7ce584395e0f3d0e0d591041e183fa97
4
- data.tar.gz: ecb38c4f7a943724caed5c6d01da58ed7bf93b210fb5e3bd0bcb710ed6b938e4
3
+ metadata.gz: c5998b08056a2657d73e7ab5382fd9ad3a96a53c9cbaa3268a98f6798c7290da
4
+ data.tar.gz: 82326fc68f9c15694f70dcbea19b43b321674f766497864c824ee574023a7f1c
5
5
  SHA512:
6
- metadata.gz: d207d98300ee79db472a0d07a2127ccb5fe0e9f6101b9187c4e83b8add90bcdd88cb4b3de3daff3d4eaa1344d7eab747f85a0cb12edf37648683450d53777aa6
7
- data.tar.gz: e70a43679205f5e8768a415fe020862c175b17f00eff0f479b5f9c5aaf053d62493f98cd09b7eaa3817f663e6d66d72f166bb6aef386de77b324154adee905cb
6
+ metadata.gz: a612ba5f45d2bd5abf09fd68e7225945e5d3defa70f0bd5298e55215010bfe71dd1a7f53adf415e126fe3a97c654fa209812e8db8b3720d8258355054767eb7d
7
+ data.tar.gz: 3649cc64a401d6d8e92b16ae8cc05999b9a6e5813a16d25a62c7661573827e3086b30e49f9d62e0c57c48a2ea6d081307a053d64d53e5de4d565db607dc76574
data/README.md CHANGED
@@ -28,10 +28,12 @@ Or in your Gemfile:
28
28
  gem "ruby-mana"
29
29
  ```
30
30
 
31
- Requires Ruby 3.3+ and an Anthropic API key:
31
+ Requires Ruby 3.3+ and an API key (Anthropic, OpenAI, or compatible):
32
32
 
33
33
  ```bash
34
34
  export ANTHROPIC_API_KEY=your_key_here
35
+ # or
36
+ export OPENAI_API_KEY=your_key_here
35
37
  ```
36
38
 
37
39
  ## Usage
@@ -146,6 +148,41 @@ Mana.configure do |c|
146
148
  end
147
149
  ```
148
150
 
151
+ ### Multiple LLM backends
152
+
153
+ Mana supports Anthropic and OpenAI-compatible APIs (including Ollama, DeepSeek, Groq, etc.):
154
+
155
+ ```ruby
156
+ # Anthropic (default for claude-* models)
157
+ Mana.configure do |c|
158
+ c.api_key = ENV["ANTHROPIC_API_KEY"]
159
+ c.model = "claude-sonnet-4-20250514"
160
+ end
161
+
162
+ # OpenAI
163
+ Mana.configure do |c|
164
+ c.api_key = ENV["OPENAI_API_KEY"]
165
+ c.base_url = "https://api.openai.com"
166
+ c.model = "gpt-4o"
167
+ end
168
+
169
+ # Ollama (local, no API key needed)
170
+ Mana.configure do |c|
171
+ c.api_key = "unused"
172
+ c.base_url = "http://localhost:11434"
173
+ c.model = "llama3"
174
+ end
175
+
176
+ # Explicit backend override
177
+ Mana.configure do |c|
178
+ c.backend = :openai # force OpenAI format
179
+ c.base_url = "https://api.groq.com/openai"
180
+ c.model = "llama-3.3-70b-versatile"
181
+ end
182
+ ```
183
+
184
+ Backend is auto-detected from model name: `claude-*` → Anthropic, everything else → OpenAI.
185
+
149
186
  ### Custom effect handlers
150
187
 
151
188
  Define your own tools that the LLM can call. Each effect becomes an LLM tool automatically — the block's keyword parameters define the tool's input schema.
@@ -217,6 +254,47 @@ Mana.incognito do
217
254
  end
218
255
  ```
219
256
 
257
+ ### Testing
258
+
259
+ Use `Mana.mock` to test code that uses `~"..."` without calling any API:
260
+
261
+ ```ruby
262
+ require "mana/test"
263
+
264
+ RSpec.describe MyApp do
265
+ include Mana::TestHelpers
266
+
267
+ it "analyzes code" do
268
+ mock_prompt "analyze", bugs: ["XSS"], score: 8.5
269
+
270
+ result = MyApp.analyze("user_input")
271
+ expect(result[:bugs]).to include("XSS")
272
+ end
273
+
274
+ it "translates with dynamic response" do
275
+ mock_prompt(/translate.*to\s+\w+/) do |prompt|
276
+ { output: prompt.include?("Chinese") ? "你好" : "hello" }
277
+ end
278
+
279
+ expect(MyApp.translate("hi", "Chinese")).to eq("你好")
280
+ end
281
+ end
282
+ ```
283
+
284
+ Block mode for inline tests:
285
+
286
+ ```ruby
287
+ Mana.mock do
288
+ prompt "summarize", summary: "A brief overview"
289
+
290
+ text = "Long article..."
291
+ ~"summarize <text> and store in <summary>"
292
+ puts summary # => "A brief overview"
293
+ end
294
+ ```
295
+
296
+ Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the stub to add.
297
+
220
298
  ### Nested prompts
221
299
 
222
300
  Functions called by LLM can themselves contain `~"..."` prompts:
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Mana
8
+ module Backends
9
+ class Anthropic < Base
10
+ def chat(system:, messages:, tools:, model:, max_tokens: 4096)
11
+ uri = URI("#{@config.base_url}/v1/messages")
12
+ body = {
13
+ model: model,
14
+ max_tokens: max_tokens,
15
+ system: system,
16
+ tools: tools,
17
+ messages: messages
18
+ }
19
+
20
+ http = Net::HTTP.new(uri.host, uri.port)
21
+ http.use_ssl = uri.scheme == "https"
22
+ http.read_timeout = 120
23
+
24
+ req = Net::HTTP::Post.new(uri)
25
+ req["Content-Type"] = "application/json"
26
+ req["x-api-key"] = @config.api_key
27
+ req["anthropic-version"] = "2023-06-01"
28
+ req.body = JSON.generate(body)
29
+
30
+ res = http.request(req)
31
+ raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
32
+
33
+ parsed = JSON.parse(res.body, symbolize_names: true)
34
+ parsed[:content] || []
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ module Backends
5
+ class Base
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ # Send a chat request, return array of content blocks in Anthropic format
11
+ # (normalized). Each backend converts its native response to this format.
12
+ # Returns: [{ type: "text", text: "..." }, { type: "tool_use", id: "...", name: "...", input: {...} }]
13
+ def chat(system:, messages:, tools:, model:, max_tokens: 4096)
14
+ raise NotImplementedError
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Mana
8
+ module Backends
9
+ class OpenAI < Base
10
+ def chat(system:, messages:, tools:, model:, max_tokens: 4096)
11
+ uri = URI("#{@config.base_url}/v1/chat/completions")
12
+ body = {
13
+ model: model,
14
+ max_completion_tokens: max_tokens,
15
+ messages: convert_messages(system, messages),
16
+ tools: convert_tools(tools)
17
+ }
18
+
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ http.use_ssl = uri.scheme == "https"
21
+ http.read_timeout = 120
22
+
23
+ req = Net::HTTP::Post.new(uri)
24
+ req["Content-Type"] = "application/json"
25
+ req["Authorization"] = "Bearer #{@config.api_key}"
26
+ req.body = JSON.generate(body)
27
+
28
+ res = http.request(req)
29
+ raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
30
+
31
+ parsed = JSON.parse(res.body, symbolize_names: true)
32
+ normalize_response(parsed)
33
+ end
34
+
35
+ private
36
+
37
+ # Convert Anthropic-style messages to OpenAI format
38
+ def convert_messages(system, messages)
39
+ result = [{ role: "system", content: system }]
40
+
41
+ messages.each do |msg|
42
+ case msg[:role]
43
+ when "user"
44
+ converted = convert_user_message(msg)
45
+ if converted.is_a?(Array)
46
+ result.concat(converted)
47
+ else
48
+ result << converted
49
+ end
50
+ when "assistant"
51
+ result << convert_assistant_message(msg)
52
+ end
53
+ end
54
+
55
+ result
56
+ end
57
+
58
+ def convert_user_message(msg)
59
+ content = msg[:content]
60
+
61
+ # Plain text user message
62
+ return { role: "user", content: content } if content.is_a?(String)
63
+
64
+ # Array of content blocks — may contain tool_result blocks
65
+ if content.is_a?(Array) && content.all? { |b| b[:type] == "tool_result" || b["type"] == "tool_result" }
66
+ # Convert each tool_result to an OpenAI tool message
67
+ return content.map do |block|
68
+ {
69
+ role: "tool",
70
+ tool_call_id: block[:tool_use_id] || block["tool_use_id"],
71
+ content: (block[:content] || block["content"]).to_s
72
+ }
73
+ end
74
+ end
75
+
76
+ # Other array content (e.g. text blocks) — join as string
77
+ if content.is_a?(Array)
78
+ texts = content.map { |b| b[:text] || b["text"] }.compact
79
+ return { role: "user", content: texts.join("\n") }
80
+ end
81
+
82
+ { role: "user", content: content.to_s }
83
+ end
84
+
85
+ def convert_assistant_message(msg)
86
+ content = msg[:content]
87
+
88
+ # Simple text response
89
+ if content.is_a?(String)
90
+ return { role: "assistant", content: content }
91
+ end
92
+
93
+ # Array of content blocks — may contain tool_use
94
+ if content.is_a?(Array)
95
+ text_parts = []
96
+ tool_calls = []
97
+
98
+ content.each do |block|
99
+ type = block[:type] || block["type"]
100
+ case type
101
+ when "text"
102
+ text_parts << (block[:text] || block["text"])
103
+ when "tool_use"
104
+ tool_calls << {
105
+ id: block[:id] || block["id"],
106
+ type: "function",
107
+ function: {
108
+ name: block[:name] || block["name"],
109
+ arguments: JSON.generate(block[:input] || block["input"] || {})
110
+ }
111
+ }
112
+ end
113
+ end
114
+
115
+ msg_hash = { role: "assistant" }
116
+ msg_hash[:content] = text_parts.join("\n") unless text_parts.empty?
117
+ msg_hash[:tool_calls] = tool_calls unless tool_calls.empty?
118
+ return msg_hash
119
+ end
120
+
121
+ { role: "assistant", content: content.to_s }
122
+ end
123
+
124
+ # Convert Anthropic tool definitions to OpenAI function calling format
125
+ def convert_tools(tools)
126
+ tools.map do |tool|
127
+ {
128
+ type: "function",
129
+ function: {
130
+ name: tool[:name],
131
+ description: tool[:description] || "",
132
+ parameters: tool[:input_schema] || {}
133
+ }
134
+ }
135
+ end
136
+ end
137
+
138
+ # Convert OpenAI response back to Anthropic-style content blocks
139
+ def normalize_response(parsed)
140
+ choice = parsed.dig(:choices, 0, :message)
141
+ return [] unless choice
142
+
143
+ blocks = []
144
+
145
+ # Text content
146
+ if choice[:content] && !choice[:content].empty?
147
+ blocks << { type: "text", text: choice[:content] }
148
+ end
149
+
150
+ # Tool calls
151
+ if choice[:tool_calls]
152
+ choice[:tool_calls].each do |tc|
153
+ func = tc[:function]
154
+ blocks << {
155
+ type: "tool_use",
156
+ id: tc[:id],
157
+ name: func[:name],
158
+ input: JSON.parse(func[:arguments], symbolize_names: true)
159
+ }
160
+ end
161
+ end
162
+
163
+ blocks
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ module Backends
5
+ ANTHROPIC_PATTERNS = /^(claude-)/i
6
+ OPENAI_PATTERNS = /^(gpt-|o1-|o3-|chatgpt-|dall-e|tts-|whisper-)/i
7
+
8
+ def self.for(config)
9
+ return config.backend if config.backend.is_a?(Base)
10
+
11
+ case config.backend&.to_s
12
+ when "openai" then OpenAI.new(config)
13
+ when "anthropic" then Anthropic.new(config)
14
+ else
15
+ # Auto-detect from model name
16
+ case config.model
17
+ when ANTHROPIC_PATTERNS then Anthropic.new(config)
18
+ else OpenAI.new(config) # Default to OpenAI (most compatible)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/mana/config.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Mana
4
4
  class Config
5
5
  attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
6
+ :backend,
6
7
  :namespace, :memory_store, :memory_path,
7
8
  :context_window, :memory_pressure, :memory_keep_recent,
8
9
  :compact_model, :on_compact
@@ -13,6 +14,7 @@ module Mana
13
14
  @api_key = ENV["ANTHROPIC_API_KEY"]
14
15
  @max_iterations = 50
15
16
  @base_url = "https://api.anthropic.com"
17
+ @backend = nil
16
18
  @namespace = nil
17
19
  @memory_store = nil
18
20
  @memory_path = nil
data/lib/mana/engine.rb CHANGED
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "net/http"
5
- require "uri"
6
4
 
7
5
  module Mana
8
6
  class Engine
@@ -120,6 +118,9 @@ module Mana
120
118
  end
121
119
 
122
120
  def execute
121
+ # Mock mode: match prompt and return stubbed values, no API calls
122
+ return handle_mock(@prompt) if Mana.mock_active?
123
+
123
124
  Thread.current[:mana_depth] ||= 0
124
125
  Thread.current[:mana_depth] += 1
125
126
  nested = Thread.current[:mana_depth] > 1
@@ -184,11 +185,46 @@ module Mana
184
185
  if nested && !@incognito
185
186
  Thread.current[:mana_memory] = outer_memory
186
187
  end
187
- Thread.current[:mana_depth] -= 1
188
+ Thread.current[:mana_depth] -= 1 if Thread.current[:mana_depth]
188
189
  end
189
190
 
190
191
  private
191
192
 
193
+ # --- Mock Handling ---
194
+
195
+ def handle_mock(prompt)
196
+ mock = Mana.current_mock
197
+ stub = mock.match(prompt)
198
+
199
+ unless stub
200
+ truncated = prompt.length > 60 ? "#{prompt[0..57]}..." : prompt
201
+ raise MockError, "No mock matched: \"#{truncated}\"\n Add: mock_prompt \"#{truncated}\", _return: \"...\""
202
+ end
203
+
204
+ values = if stub.block
205
+ stub.block.call(prompt)
206
+ else
207
+ stub.values.dup
208
+ end
209
+
210
+ return_value = values.delete(:_return)
211
+
212
+ values.each do |name, value|
213
+ write_local(name.to_s, value)
214
+ end
215
+
216
+ # Record in short-term memory if not incognito
217
+ if !@incognito
218
+ memory = Memory.current
219
+ if memory
220
+ memory.short_term << { role: "user", content: prompt }
221
+ memory.short_term << { role: "assistant", content: [{ type: "text", text: "Done: #{return_value || values.inspect}" }] }
222
+ end
223
+ end
224
+
225
+ return_value || values.values.first
226
+ end
227
+
192
228
  # --- Context Building ---
193
229
 
194
230
  def build_context(prompt)
@@ -410,30 +446,14 @@ module Mana
410
446
  # --- LLM Client ---
411
447
 
412
448
  def llm_call(system, messages)
413
- uri = URI("#{@config.base_url}/v1/messages")
414
- body = {
415
- model: @config.model,
416
- max_tokens: 4096,
449
+ backend = Backends.for(@config)
450
+ backend.chat(
417
451
  system: system,
452
+ messages: messages,
418
453
  tools: self.class.all_tools,
419
- messages: messages
420
- }
421
-
422
- http = Net::HTTP.new(uri.host, uri.port)
423
- http.use_ssl = uri.scheme == "https"
424
- http.read_timeout = 120
425
-
426
- req = Net::HTTP::Post.new(uri)
427
- req["Content-Type"] = "application/json"
428
- req["x-api-key"] = @config.api_key
429
- req["anthropic-version"] = "2023-06-01"
430
- req.body = JSON.generate(body)
431
-
432
- res = http.request(req)
433
- raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
434
-
435
- parsed = JSON.parse(res.body, symbolize_names: true)
436
- parsed[:content] || []
454
+ model: @config.model,
455
+ max_tokens: 4096
456
+ )
437
457
  end
438
458
 
439
459
  def extract_tool_uses(content)
data/lib/mana/mock.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ class Mock
5
+ Stub = Struct.new(:pattern, :values, :block, keyword_init: true)
6
+
7
+ attr_reader :stubs
8
+
9
+ def initialize
10
+ @stubs = []
11
+ end
12
+
13
+ def prompt(pattern, **values, &block)
14
+ @stubs << Stub.new(pattern: pattern, values: values, block: block)
15
+ end
16
+
17
+ def match(prompt_text)
18
+ @stubs.find do |stub|
19
+ case stub.pattern
20
+ when Regexp then prompt_text.match?(stub.pattern)
21
+ when String then prompt_text.include?(stub.pattern)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def mock(&block)
29
+ old_mock = Thread.current[:mana_mock]
30
+ m = Mock.new
31
+ Thread.current[:mana_mock] = m
32
+ m.instance_eval(&block)
33
+ ensure
34
+ Thread.current[:mana_mock] = old_mock
35
+ end
36
+
37
+ def mock!
38
+ Thread.current[:mana_mock] = Mock.new
39
+ end
40
+
41
+ def unmock!
42
+ Thread.current[:mana_mock] = nil
43
+ end
44
+
45
+ def mock_active?
46
+ !Thread.current[:mana_mock].nil?
47
+ end
48
+
49
+ def current_mock
50
+ Thread.current[:mana_mock]
51
+ end
52
+ end
53
+ end
data/lib/mana/test.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mana"
4
+
5
+ module Mana
6
+ module TestHelpers
7
+ def self.included(base)
8
+ base.before { Mana.mock! }
9
+ base.after { Mana.unmock! }
10
+ end
11
+
12
+ def mock_prompt(pattern, **values, &block)
13
+ raise Mana::MockError, "Mana mock mode not active. Call Mana.mock! first" unless Mana.mock_active?
14
+
15
+ Mana.current_mock.prompt(pattern, **values, &block)
16
+ end
17
+ end
18
+ end
data/lib/mana/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mana
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/mana.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  require_relative "mana/version"
4
4
  require_relative "mana/config"
5
+ require_relative "mana/backends/base"
6
+ require_relative "mana/backends/anthropic"
7
+ require_relative "mana/backends/openai"
8
+ require_relative "mana/backends/registry"
5
9
  require_relative "mana/effect_registry"
6
10
  require_relative "mana/namespace"
7
11
  require_relative "mana/memory_store"
@@ -17,6 +21,7 @@ module Mana
17
21
  class Error < StandardError; end
18
22
  class MaxIterationsError < Error; end
19
23
  class LLMError < Error; end
24
+ class MockError < Error; end
20
25
 
21
26
  class << self
22
27
  def config
@@ -40,6 +45,7 @@ module Mana
40
45
  @config = Config.new
41
46
  EffectRegistry.clear!
42
47
  Thread.current[:mana_memory] = nil
48
+ Thread.current[:mana_mock] = nil
43
49
  end
44
50
 
45
51
  # Define a custom effect that becomes an LLM tool
@@ -73,3 +79,5 @@ module Mana
73
79
  end
74
80
  end
75
81
  end
82
+
83
+ require_relative "mana/mock"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-mana
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl
@@ -37,6 +37,10 @@ files:
37
37
  - LICENSE
38
38
  - README.md
39
39
  - lib/mana.rb
40
+ - lib/mana/backends/anthropic.rb
41
+ - lib/mana/backends/base.rb
42
+ - lib/mana/backends/openai.rb
43
+ - lib/mana/backends/registry.rb
40
44
  - lib/mana/compiler.rb
41
45
  - lib/mana/config.rb
42
46
  - lib/mana/context_window.rb
@@ -46,8 +50,10 @@ files:
46
50
  - lib/mana/memory.rb
47
51
  - lib/mana/memory_store.rb
48
52
  - lib/mana/mixin.rb
53
+ - lib/mana/mock.rb
49
54
  - lib/mana/namespace.rb
50
55
  - lib/mana/string_ext.rb
56
+ - lib/mana/test.rb
51
57
  - lib/mana/version.rb
52
58
  homepage: https://github.com/carlnoah6/ruby-mana
53
59
  licenses: