ruby-mana 0.5.1 → 0.5.7

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.
@@ -1,17 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
3
7
  module Mana
4
8
  module Backends
9
+ # Base class for LLM backends. Provides shared HTTP infrastructure
10
+ # and the factory method for backend resolution.
5
11
  class Base
12
+ # Model name pattern for auto-detecting Anthropic backend
13
+ ANTHROPIC_PATTERN = /^claude-/i
14
+
6
15
  def initialize(config)
7
16
  @config = config
8
17
  end
9
18
 
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: {...} }]
19
+ # Send a chat request. Subclasses must implement this.
13
20
  def chat(system:, messages:, tools:, model:, max_tokens: 4096)
14
- raise NotImplementedError
21
+ raise NotImplementedError, "#{self.class}#chat not implemented"
22
+ end
23
+
24
+ # --- Factory method ---
25
+
26
+ # Resolve a backend instance from configuration.
27
+ # Priority: pre-built instance > explicit name > auto-detect from model name.
28
+ def self.for(config)
29
+ return config.backend if config.backend.is_a?(Base)
30
+
31
+ config.validate!
32
+
33
+ case config.backend&.to_s
34
+ when "openai" then OpenAI.new(config)
35
+ when "anthropic" then Anthropic.new(config)
36
+ else
37
+ config.model.match?(ANTHROPIC_PATTERN) ? Anthropic.new(config) : OpenAI.new(config)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Shared HTTP POST with error handling and timeout support.
44
+ # Returns parsed JSON response body.
45
+ def http_post(uri, body, headers = {})
46
+ http = Net::HTTP.new(uri.host, uri.port)
47
+ http.use_ssl = uri.scheme == "https"
48
+ http.open_timeout = @config.timeout
49
+ http.read_timeout = @config.timeout
50
+
51
+ req = Net::HTTP::Post.new(uri)
52
+ req["Content-Type"] = "application/json"
53
+ headers.each { |k, v| req[k] = v }
54
+ req.body = JSON.generate(body)
55
+
56
+ res = http.request(req)
57
+ raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
58
+
59
+ JSON.parse(res.body, symbolize_names: true)
60
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
61
+ raise LLMError, "Request timed out: #{e.message}"
15
62
  end
16
63
  end
17
64
  end
@@ -1,40 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
-
7
3
  module Mana
8
4
  module Backends
5
+ # OpenAI-compatible backend (GPT, Groq, DeepSeek, Ollama, etc.)
6
+ #
7
+ # Translates between Mana's internal format (Anthropic-style) and OpenAI's:
8
+ # - system prompt: top-level `system` → system message
9
+ # - tool calls: `tool_use`/`tool_result` blocks → `tool_calls` + `role: "tool"`
10
+ # - response: `choices` → content blocks
9
11
  class OpenAI < Base
10
12
  def chat(system:, messages:, tools:, model:, max_tokens: 4096)
11
- uri = URI("#{@config.base_url}/v1/chat/completions")
12
- body = {
13
+ uri = URI("#{@config.effective_base_url}/v1/chat/completions")
14
+ parsed = http_post(uri, {
13
15
  model: model,
14
16
  max_completion_tokens: max_tokens,
15
17
  messages: convert_messages(system, messages),
16
18
  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)
19
+ }, {
20
+ "Authorization" => "Bearer #{@config.api_key}"
21
+ })
32
22
  normalize_response(parsed)
33
23
  end
34
24
 
35
25
  private
36
26
 
37
- # Convert Anthropic-style messages to OpenAI format
27
+ # Convert Anthropic-style messages to OpenAI format.
38
28
  def convert_messages(system, messages)
39
29
  result = [{ role: "system", content: system }]
40
30
 
@@ -42,11 +32,7 @@ module Mana
42
32
  case msg[:role]
43
33
  when "user"
44
34
  converted = convert_user_message(msg)
45
- if converted.is_a?(Array)
46
- result.concat(converted)
47
- else
48
- result << converted
49
- end
35
+ converted.is_a?(Array) ? result.concat(converted) : result << converted
50
36
  when "assistant"
51
37
  result << convert_assistant_message(msg)
52
38
  end
@@ -58,12 +44,9 @@ module Mana
58
44
  def convert_user_message(msg)
59
45
  content = msg[:content]
60
46
 
61
- # Plain text user message
62
47
  return { role: "user", content: content } if content.is_a?(String)
63
48
 
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
49
+ if content.is_a?(Array) && content.all? { |b| (b[:type] || b["type"]) == "tool_result" }
67
50
  return content.map do |block|
68
51
  {
69
52
  role: "tool",
@@ -73,7 +56,6 @@ module Mana
73
56
  end
74
57
  end
75
58
 
76
- # Other array content (e.g. text blocks) — join as string
77
59
  if content.is_a?(Array)
78
60
  texts = content.map { |b| b[:text] || b["text"] }.compact
79
61
  return { role: "user", content: texts.join("\n") }
@@ -85,12 +67,8 @@ module Mana
85
67
  def convert_assistant_message(msg)
86
68
  content = msg[:content]
87
69
 
88
- # Simple text response
89
- if content.is_a?(String)
90
- return { role: "assistant", content: content }
91
- end
70
+ return { role: "assistant", content: content } if content.is_a?(String)
92
71
 
93
- # Array of content blocks — may contain tool_use
94
72
  if content.is_a?(Array)
95
73
  text_parts = []
96
74
  tool_calls = []
@@ -121,7 +99,6 @@ module Mana
121
99
  { role: "assistant", content: content.to_s }
122
100
  end
123
101
 
124
- # Convert Anthropic tool definitions to OpenAI function calling format
125
102
  def convert_tools(tools)
126
103
  tools.map do |tool|
127
104
  {
@@ -129,25 +106,23 @@ module Mana
129
106
  function: {
130
107
  name: tool[:name],
131
108
  description: tool[:description] || "",
132
- parameters: tool[:input_schema] || {}
109
+ parameters: (tool[:input_schema] || {}).reject { |k, _| k.to_s == "$schema" }
133
110
  }
134
111
  }
135
112
  end
136
113
  end
137
114
 
138
- # Convert OpenAI response back to Anthropic-style content blocks
115
+ # Convert OpenAI response to Anthropic-style content blocks.
139
116
  def normalize_response(parsed)
140
117
  choice = parsed.dig(:choices, 0, :message)
141
118
  return [] unless choice
142
119
 
143
120
  blocks = []
144
121
 
145
- # Text content
146
122
  if choice[:content] && !choice[:content].empty?
147
123
  blocks << { type: "text", text: choice[:content] }
148
124
  end
149
125
 
150
- # Tool calls
151
126
  if choice[:tool_calls]
152
127
  choice[:tool_calls].each do |tc|
153
128
  func = tc[:function]
data/lib/mana/compiler.rb CHANGED
@@ -1,20 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "digest"
4
5
 
5
6
  module Mana
6
7
  # Compiler for `mana def` — LLM generates method implementations on first call,
7
8
  # caches them as real .rb files, and replaces the method with native Ruby.
8
9
  #
9
10
  # Usage:
10
- # mana def fizzbuzz(n)
11
- # ~"return an array of FizzBuzz results from 1 to n"
11
+ # mana def fibonacci(n)
12
+ # ~"return an array of the first n Fibonacci numbers"
12
13
  # end
13
14
  #
14
- # fizzbuzz(15) # first call → LLM generates code → cached → executed
15
- # fizzbuzz(20) # subsequent calls → pure Ruby, zero API overhead
15
+ # fibonacci(10) # first call → LLM generates code → cached → executed
16
+ # fibonacci(20) # subsequent calls → pure Ruby, zero API overhead
16
17
  #
17
- # Mana.source(:fizzbuzz) # view generated source
18
+ # Mana.source(:fibonacci) # view generated source
18
19
  module Compiler
19
20
  class << self
20
21
  # Registry of compiled method sources: { "ClassName#method" => source_code }
@@ -35,55 +36,135 @@ module Mana
35
36
  registry[key]
36
37
  end
37
38
 
38
- # Compile a method: wrap it so first invocation triggers LLM code generation
39
+ # Compile a method: wrap it so first invocation triggers LLM code generation.
40
+ # On subsequent calls, the generated Ruby code is loaded from cache (zero API cost).
39
41
  def compile(owner, method_name)
40
42
  original = owner.instance_method(method_name)
41
43
  compiler = self
42
44
  key = registry_key(method_name, owner)
43
45
 
44
- # Read the prompt from the original method body
46
+ # Detect method visibility before we replace it
47
+ visibility = if owner.private_method_defined?(method_name)
48
+ :private
49
+ elsif owner.protected_method_defined?(method_name)
50
+ :protected
51
+ else
52
+ :public
53
+ end
54
+
55
+ # Read the prompt from the original method body (the ~"..." string)
45
56
  prompt = extract_prompt(original)
46
57
 
47
58
  # Build parameter signature for the generated method
48
59
  params_desc = describe_params(original)
49
60
 
61
+ # Cache filename based on source file + method name
62
+ source_file = original.source_location&.first
63
+ # Include gem version, Ruby version, and sibling function signatures so cache
64
+ # auto-invalidates when the gem upgrades, Ruby upgrades, or dependency functions change.
65
+ sibling_methods = begin
66
+ Mana::Introspect.methods_from_file(source_file)
67
+ .reject { |m| m[:name] == method_name.to_s }
68
+ .map { |m| "#{m[:name]}(#{m[:params].join(',')})" }
69
+ .sort.join(";")
70
+ rescue
71
+ ""
72
+ end
73
+ prompt_hash = Digest::SHA256.hexdigest("#{Mana::VERSION}:#{RUBY_VERSION}:#{method_name}:#{params_desc}:#{prompt}:#{sibling_methods}")[0, 16]
74
+ cache_path = cache_file_path(method_name, owner, source_file: source_file)
75
+
76
+ # Load from cache if file exists and prompt hash matches
77
+ if File.exist?(cache_path)
78
+ first_line = File.open(cache_path, &:readline) rescue ""
79
+ if first_line.include?(prompt_hash)
80
+ cached = File.read(cache_path)
81
+ generated = cached.lines.reject { |l| l.start_with?("#") }.join.strip
82
+ compiler.registry[key] = generated
83
+ v, $VERBOSE = $VERBOSE, nil
84
+ owner.class_eval(generated, cache_path, 1)
85
+ owner.send(visibility, method_name) unless visibility == :public
86
+ $VERBOSE = v
87
+ return
88
+ end
89
+ # Prompt changed — cache is stale, will regenerate on first call
90
+ end
91
+
92
+ # Replace the method with a lazy wrapper that generates code on first call
93
+ old_verbose, $VERBOSE = $VERBOSE, nil
94
+ p_hash = prompt_hash # capture for closure
95
+ p_text = prompt # capture for closure
96
+ src_file = source_file # capture for closure
50
97
  owner.define_method(method_name) do |*args, **kwargs, &blk|
51
98
  # Generate implementation via LLM
52
99
  generated = compiler.generate(method_name, params_desc, prompt)
53
100
 
54
- # Write to cache file
55
- cache_path = compiler.write_cache(method_name, generated, owner)
101
+ # Write to cache file for future runs
102
+ cache_path = compiler.write_cache(method_name, generated, owner, prompt_hash: p_hash, prompt: p_text, source_file: src_file)
56
103
 
57
- # Store in registry
104
+ # Store in registry so Mana.source() can retrieve it
58
105
  compiler.registry[key] = generated
59
106
 
60
107
  # Define the method on the correct owner (not Object) via class_eval
61
108
  target_owner = owner
109
+ v, $VERBOSE = $VERBOSE, nil
62
110
  target_owner.class_eval(generated, cache_path, 1)
111
+ target_owner.send(visibility, method_name) unless visibility == :public
112
+ $VERBOSE = v
63
113
 
64
- # Call the now-native method
114
+ # Call the now-native method (this wrapper never runs again)
65
115
  send(method_name, *args, **kwargs, &blk)
66
116
  end
117
+ # Restore original visibility on the wrapper method
118
+ owner.send(visibility, method_name) unless visibility == :public
119
+ $VERBOSE = old_verbose
67
120
  end
68
121
 
69
- # Generate Ruby method source via LLM
122
+ # Generate Ruby method source via LLM.
123
+ # Uses an isolated binding so LLM cannot see Compiler internals.
70
124
  def generate(method_name, params_desc, prompt)
71
- code = nil # pre-declare so binding captures it
72
- b = binding
73
125
  engine_prompt = "Write a Ruby method definition `def #{method_name}(#{params_desc})` that: #{prompt}. " \
74
126
  "Return ONLY the complete method definition (def...end), no explanation. " \
75
127
  "Store the code as a string in <code>"
76
128
 
77
- Mana::Engines::LLM.new(b).execute(engine_prompt)
129
+ # Create isolated binding with only `code` variable visible.
130
+ # Use eval to avoid "assigned but unused variable" parse-time warning.
131
+ isolated = Object.new.instance_eval { eval("code = nil; binding") }
132
+ Mana::Engine.new(isolated).execute(engine_prompt)
133
+
134
+ code = isolated.local_variable_get(:code)
135
+ # LLM may return literal \n instead of real newlines — unescape them
136
+ code = code.gsub("\\n", "\n").gsub("\\\"", "\"").gsub("\\'", "'") if code.is_a?(String)
78
137
  code
79
138
  end
80
139
 
140
+ # Path to the cache file for a method.
141
+ # Includes source file path for uniqueness: lib_foo_calculate.rb
142
+ # Build the cache file path for a method.
143
+ # Prefers source-file-based naming for uniqueness; falls back to owner class name.
144
+ def cache_file_path(method_name, owner = nil, source_file: nil)
145
+ parts = []
146
+ if source_file
147
+ # Convert path relative to pwd: lib/foo.rb -> lib_foo
148
+ rel = source_file.sub("#{Dir.pwd}/", "").sub(/\.rb$/, "")
149
+ parts << rel.tr("/", "_")
150
+ elsif owner && owner != Object
151
+ # Use underscored class name when source file is unavailable
152
+ parts << underscore(owner.name)
153
+ end
154
+ parts << method_name.to_s
155
+ File.join(cache_dir, "#{parts.join('_')}.rb")
156
+ end
157
+
81
158
  # Write generated code to a cache file, return the path
82
- def write_cache(method_name, source, owner = nil)
159
+ def write_cache(method_name, source, owner = nil, prompt_hash: nil, prompt: nil, source_file: nil)
83
160
  FileUtils.mkdir_p(cache_dir)
84
- prefix = owner && owner != Object ? "#{underscore(owner.name)}_" : ""
85
- path = File.join(cache_dir, "#{prefix}#{method_name}.rb")
86
- File.write(path, "# Auto-generated by ruby-mana\n# frozen_string_literal: true\n\n#{source}\n")
161
+ path = cache_file_path(method_name, owner, source_file: source_file)
162
+ header = "# Auto-generated by ruby-mana v#{Mana::VERSION} | ruby #{RUBY_VERSION} | prompt_hash: #{prompt_hash}\n"
163
+ if prompt
164
+ prompt.to_s.each_line { |line| header += "# prompt: #{line.rstrip}\n" }
165
+ end
166
+ header += "# frozen_string_literal: true\n\n"
167
+ File.write(path, "#{header}#{source}\n")
87
168
  path
88
169
  end
89
170
 
@@ -104,45 +185,80 @@ module Mana
104
185
  end
105
186
 
106
187
  # Extract the prompt string from the original method.
107
- # The method body should be a single ~"..." expression.
188
+ # Strategy 1: Parse source file with Prism AST (handles multi-line, heredoc, escapes)
189
+ # Strategy 2: Extract from instruction sequence (fallback for IRB/eval)
108
190
  def extract_prompt(unbound_method)
109
191
  source_loc = unbound_method.source_location
110
- return nil unless source_loc
192
+ return extract_prompt_from_iseq(unbound_method) unless source_loc
111
193
 
112
194
  file, line = source_loc
113
- return nil unless file && File.exist?(file)
114
-
115
- lines = File.readlines(file)
116
- # Scan from the def line to find the prompt string
117
- body_lines = []
118
- depth = 0
119
- (line - 1...lines.length).each do |i|
120
- l = lines[i]
121
- depth += l.scan(/\bdef\b|\bdo\b|\bclass\b|\bmodule\b|\bif\b|\bunless\b|\bcase\b|\bwhile\b|\buntil\b|\bbegin\b/).length
122
- depth -= l.scan(/\bend\b/).length
123
- body_lines << l
124
- break if depth <= 0
195
+ return extract_prompt_from_iseq(unbound_method) unless file && File.exist?(file)
196
+
197
+ # Parse with Prism AST — finds ~@ call on a string node
198
+ source = File.read(file)
199
+ result = Prism.parse(source)
200
+
201
+ # Walk AST to find the DefNode at the right line, then find ~@ inside it
202
+ prompt = nil
203
+ queue = [result.value]
204
+ while (node = queue.shift)
205
+ next unless node.respond_to?(:compact_child_nodes)
206
+
207
+ if node.is_a?(Prism::DefNode) && node.location.start_line == line
208
+ # Found our method — now find the ~"..." call inside
209
+ inner_queue = node.compact_child_nodes.dup
210
+ while (inner = inner_queue.shift)
211
+ next unless inner.respond_to?(:compact_child_nodes)
212
+ if inner.is_a?(Prism::CallNode) && inner.name == :~@ && inner.receiver.is_a?(Prism::StringNode)
213
+ prompt = inner.receiver.unescaped
214
+ break
215
+ end
216
+ inner_queue.concat(inner.compact_child_nodes)
217
+ end
218
+ break
219
+ end
220
+
221
+ queue.concat(node.compact_child_nodes)
125
222
  end
126
223
 
127
- # Extract string content from ~"..." pattern
128
- body = body_lines.join
129
- match = body.match(/~"([^"]*)"/) || body.match(/~'([^']*)'/)
130
- match ? match[1] : body_lines[1...-1].join.strip
224
+ prompt || extract_prompt_from_iseq(unbound_method)
225
+ end
226
+
227
+ # Fallback: extract prompt from method instruction sequence (works in IRB/eval).
228
+ # Uses iseq.to_a to find the string literal directly — more reliable than disasm
229
+ # because it preserves real newlines (disasm escapes them as \\n).
230
+ def extract_prompt_from_iseq(unbound_method)
231
+ iseq = RubyVM::InstructionSequence.of(unbound_method)
232
+ return nil unless iseq
233
+
234
+ # Walk the flattened instruction array to find putstring/putchilledstring
235
+ flat = iseq.to_a.flatten
236
+ flat.each_with_index do |item, i|
237
+ if (item == :putchilledstring || item == :putstring) && flat[i + 1].is_a?(String)
238
+ return flat[i + 1]
239
+ end
240
+ end
241
+ nil
242
+ rescue
243
+ nil
131
244
  end
132
245
 
246
+ # Build a human-readable parameter signature string from method parameters.
247
+ # Maps each parameter type to its Ruby syntax representation.
133
248
  def describe_params(unbound_method)
134
249
  unbound_method.parameters.map do |(type, name)|
135
250
  case type
136
- when :req then name.to_s
137
- when :opt then "#{name}=nil"
138
- when :rest then "*#{name}"
139
- when :keyreq then "#{name}:"
140
- when :key then "#{name}: nil"
141
- when :keyrest then "**#{name}"
142
- when :block then "&#{name}"
143
- else name.to_s
251
+ when :req then name.to_s # required positional
252
+ when :opt then "#{name}=nil" # optional positional
253
+ when :rest then "*#{name}" # splat
254
+ when :keyreq then "#{name}:" # required keyword
255
+ when :key then "#{name}: nil" # optional keyword
256
+ when :keyrest then "**#{name}" # double splat
257
+ when :block then "&#{name}" # block parameter
258
+ when :nokey then nil # **nil — skip (no keywords accepted)
259
+ else name&.to_s
144
260
  end
145
- end.join(", ")
261
+ end.compact.join(", ")
146
262
  end
147
263
 
148
264
  def underscore(str)
data/lib/mana/config.rb CHANGED
@@ -1,28 +1,116 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mana
4
+ # Central configuration for Mana. Set via Mana.configure { |c| ... }.
5
+ #
6
+ # Key options:
7
+ # model - LLM model name (default: claude-sonnet-4-6)
8
+ # api_key - API key, falls back to ANTHROPIC_API_KEY or OPENAI_API_KEY env vars
9
+ # base_url - Custom API endpoint, falls back to ANTHROPIC_API_URL or OPENAI_API_URL
10
+ # backend - :anthropic, :openai, or nil (auto-detect from model name)
11
+ # timeout - HTTP timeout in seconds (default: 120)
12
+ # memory_pressure - Token ratio (0-1) that triggers memory compaction (default: 0.7)
4
13
  class Config
5
14
  attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
6
- :backend,
15
+ :backend, :verbose,
7
16
  :namespace, :memory_store, :memory_path,
8
17
  :context_window, :memory_pressure, :memory_keep_recent,
9
18
  :compact_model, :on_compact
19
+ attr_reader :timeout
10
20
 
21
+ DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com"
22
+ DEFAULT_OPENAI_URL = "https://api.openai.com"
23
+
24
+ # All config options can be set via environment variables:
25
+ # MANA_MODEL, MANA_VERBOSE, MANA_TIMEOUT, MANA_BACKEND
26
+ # ANTHROPIC_API_KEY / OPENAI_API_KEY
27
+ # ANTHROPIC_API_URL / OPENAI_API_URL
11
28
  def initialize
12
- @model = "claude-sonnet-4-20250514"
29
+ @model = ENV["MANA_MODEL"] || "claude-sonnet-4-6"
13
30
  @temperature = 0
14
- @api_key = ENV["ANTHROPIC_API_KEY"]
31
+ @api_key = ENV["ANTHROPIC_API_KEY"] || ENV["OPENAI_API_KEY"]
15
32
  @max_iterations = 50
16
- @base_url = "https://api.anthropic.com"
17
- @backend = nil
33
+ @base_url = ENV["ANTHROPIC_API_URL"] || ENV["OPENAI_API_URL"]
34
+ self.timeout = (ENV["MANA_TIMEOUT"] || 120).to_i
35
+ @verbose = %w[1 true yes].include?(ENV["MANA_VERBOSE"]&.downcase)
36
+ @backend = ENV["MANA_BACKEND"]&.to_sym
37
+ sec = ENV["MANA_SECURITY"]
38
+ @security = SecurityPolicy.new(sec ? sec.to_sym : :standard)
18
39
  @namespace = nil
19
40
  @memory_store = nil
20
41
  @memory_path = nil
21
- @context_window = nil
42
+ @context_window = 128_000
22
43
  @memory_pressure = 0.7
23
44
  @memory_keep_recent = 4
24
45
  @compact_model = nil
25
46
  @on_compact = nil
26
47
  end
48
+
49
+ # Set timeout; must be a positive number
50
+ def timeout=(value)
51
+ unless value.is_a?(Numeric) && value.positive?
52
+ raise ArgumentError, "timeout must be a positive number, got #{value.inspect}"
53
+ end
54
+
55
+ @timeout = value
56
+ end
57
+
58
+ # Read the current security policy
59
+ def security
60
+ @security
61
+ end
62
+
63
+ # Accept Symbol (:strict), Integer (1), or SecurityPolicy instance
64
+ def security=(value)
65
+ case value
66
+ when SecurityPolicy
67
+ @security = value
68
+ when Symbol, Integer
69
+ @security = SecurityPolicy.new(value)
70
+ else
71
+ raise ArgumentError, "security must be a Symbol, Integer, or SecurityPolicy, got #{value.class}"
72
+ end
73
+ end
74
+
75
+ # Resolve the effective base URL based on the configured or auto-detected backend.
76
+ # Falls back to the appropriate default when no explicit URL is set.
77
+ def effective_base_url
78
+ # Return user-configured URL if explicitly set
79
+ return @base_url if @base_url
80
+
81
+ # Otherwise pick the default URL based on backend type
82
+ if anthropic_backend?
83
+ DEFAULT_ANTHROPIC_URL
84
+ else
85
+ DEFAULT_OPENAI_URL
86
+ end
87
+ end
88
+
89
+ # Validate configuration and raise early if something is wrong.
90
+ # Called automatically by Mana.configure, or manually via Mana.config.validate!
91
+ def validate!
92
+ if @api_key.nil? || @api_key.to_s.strip.empty?
93
+ raise ConfigError,
94
+ "API key is not configured. Set it via environment variable or Mana.configure:\n\n" \
95
+ " export ANTHROPIC_API_KEY=your_key_here\n" \
96
+ " # or\n" \
97
+ " export OPENAI_API_KEY=your_key_here\n" \
98
+ " # or\n" \
99
+ " Mana.configure { |c| c.api_key = \"your_key_here\" }\n"
100
+ end
101
+ true
102
+ end
103
+
104
+ private
105
+
106
+ # Determine whether the current backend is Anthropic
107
+ def anthropic_backend?
108
+ case @backend&.to_s
109
+ when "anthropic" then true
110
+ when "openai" then false
111
+ # No explicit backend; auto-detect from model name pattern
112
+ else @model.match?(Backends::Base::ANTHROPIC_PATTERN)
113
+ end
114
+ end
27
115
  end
28
116
  end