ruby-mana 0.3.1 → 0.5.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/CHANGELOG.md +41 -1
- data/README.md +147 -1
- data/data/lang-rules.yml +196 -0
- data/lib/mana/backends/anthropic.rb +38 -0
- data/lib/mana/backends/base.rb +18 -0
- data/lib/mana/backends/openai.rb +167 -0
- data/lib/mana/backends/registry.rb +23 -0
- data/lib/mana/compiler.rb +1 -1
- data/lib/mana/config.rb +2 -0
- data/lib/mana/engine.rb +38 -412
- data/lib/mana/engines/base.rb +51 -0
- data/lib/mana/engines/detect.rb +93 -0
- data/lib/mana/engines/javascript.rb +90 -0
- data/lib/mana/engines/llm.rb +459 -0
- data/lib/mana/engines/python.rb +230 -0
- data/lib/mana/engines/ruby_eval.rb +11 -0
- data/lib/mana/mock.rb +53 -0
- data/lib/mana/test.rb +18 -0
- data/lib/mana/version.rb +1 -1
- data/lib/mana.rb +16 -0
- metadata +29 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04d7095024d30500930422b72e86fcdb1a27b1fa9a9f62ca70c9c124c2ceab77
|
|
4
|
+
data.tar.gz: 6e264f8292e908d45776481aa6af33bed5eb7d9113874d13e72f3a3cde513107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 470e54537747878bbe07c6825a12bf515a2909af872ee4908e0eb07a0e58b31187dacadb6b20d988782a8f9a2bfaf12c4523ae09c149dc764ad90c2b8a420871
|
|
7
|
+
data.tar.gz: 1c322b45e74e8fa67aab109c10a5a7cac2d6546d1de61912157192e9f15330b8c2520bd5e90b4c70b942d36b7de4ecabfc165c2774be4b8489936e68e14614a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.5.0] - 2026-02-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Polyglot engine architecture** — run Ruby, JavaScript, and Python code side by side
|
|
7
|
+
- JavaScript engine via `mini_racer` with automatic variable bridging
|
|
8
|
+
- Python engine via `pycall` with automatic variable bridging
|
|
9
|
+
- Bidirectional Python ↔ Ruby calling through pycall bridge
|
|
10
|
+
- Engine interface abstraction (`Mana::Engines::Base`)
|
|
11
|
+
- Language auto-detection for polyglot dispatch
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Refactored LLM logic into `Engines::LLM`, extracted from monolithic core
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Polyglot engine bug fixes (CodeRabbit review feedback)
|
|
18
|
+
|
|
19
|
+
## [0.4.0] - 2026-02-22
|
|
20
|
+
|
|
21
|
+
- Multi-LLM backend — Anthropic + OpenAI-compatible APIs
|
|
22
|
+
- Test mode — `Mana.mock` + `mock_prompt` for stubbing LLM responses
|
|
23
|
+
|
|
24
|
+
## [0.3.1] - 2026-02-21
|
|
25
|
+
|
|
26
|
+
- Nested prompts — LLM calling LLM
|
|
27
|
+
- Lambda `call_func` support
|
|
28
|
+
- Expanded test coverage
|
|
29
|
+
|
|
30
|
+
## [0.3.0] - 2026-02-21
|
|
31
|
+
|
|
32
|
+
- Automatic memory — context sharing across LLM calls
|
|
33
|
+
- Incognito mode
|
|
34
|
+
- Persistent long-term memory
|
|
35
|
+
- `Mana.session` — shared conversation context across prompts
|
|
36
|
+
|
|
37
|
+
## [0.2.0] - 2026-02-20
|
|
38
|
+
|
|
39
|
+
- Custom effect handlers — user-defined LLM tools
|
|
40
|
+
- `mana def` — LLM-compiled methods with file caching
|
|
41
|
+
- Auto-discover functions via Prism introspection
|
|
42
|
+
|
|
43
|
+
## [0.1.0] - 2026-02-19
|
|
4
44
|
|
|
5
45
|
- Initial release
|
|
6
46
|
- `~"..."` syntax for embedding LLM prompts in Ruby
|
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
|
|
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,115 @@ Mana.incognito do
|
|
|
217
254
|
end
|
|
218
255
|
```
|
|
219
256
|
|
|
257
|
+
### Polyglot — Cross-Language Interop
|
|
258
|
+
|
|
259
|
+
`~"..."` is a universal operator. It detects whether the code is JavaScript, Python, Ruby, or natural language, and routes to the appropriate engine. Variables bridge automatically.
|
|
260
|
+
|
|
261
|
+
#### JavaScript
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
require "mana"
|
|
265
|
+
|
|
266
|
+
data = [1, 2, 3, 4, 5]
|
|
267
|
+
|
|
268
|
+
# JavaScript — auto-detected from syntax
|
|
269
|
+
~"const evens = data.filter(n => n % 2 === 0)"
|
|
270
|
+
puts evens # => [2, 4]
|
|
271
|
+
|
|
272
|
+
# Multi-line with heredoc
|
|
273
|
+
~<<~JS
|
|
274
|
+
const sum = evens.reduce((a, b) => a + b, 0)
|
|
275
|
+
const avg = sum / evens.length
|
|
276
|
+
JS
|
|
277
|
+
puts avg # => 3.0
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
#### Python
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
# Python — auto-detected
|
|
284
|
+
~"evens = [n for n in data if n % 2 == 0]"
|
|
285
|
+
puts evens # => [2, 4]
|
|
286
|
+
|
|
287
|
+
# Multi-line
|
|
288
|
+
~<<~PY
|
|
289
|
+
import statistics
|
|
290
|
+
mean = statistics.mean(data)
|
|
291
|
+
stdev = statistics.stdev(data)
|
|
292
|
+
PY
|
|
293
|
+
puts mean # => 3.0
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### Natural language (LLM) — existing behavior
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
~"analyze <data> and find outliers, store in <result>"
|
|
300
|
+
puts result
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
#### How detection works
|
|
304
|
+
|
|
305
|
+
- Auto-detects from code syntax (token patterns)
|
|
306
|
+
- Context-aware: consecutive `~"..."` calls tend to stay in the same language
|
|
307
|
+
- Override with `Mana.engine = :javascript` or `Mana.with(:python) { ... }`
|
|
308
|
+
- Detection rules are defined in `data/lang-rules.yml` — transparent, no black box
|
|
309
|
+
|
|
310
|
+
#### Variable bridging
|
|
311
|
+
|
|
312
|
+
- Simple types (numbers, strings, booleans, nil, arrays, hashes) are copied
|
|
313
|
+
- Each engine maintains a persistent context (V8 for JS, Python interpreter for Python)
|
|
314
|
+
- Variables created in one `~"..."` call persist for the next call in the same engine
|
|
315
|
+
|
|
316
|
+
#### Setup
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
# Gemfile
|
|
320
|
+
gem "mana"
|
|
321
|
+
gem "mini_racer" # for JavaScript support (optional)
|
|
322
|
+
gem "pycall" # for Python support (optional)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Testing
|
|
326
|
+
|
|
327
|
+
Use `Mana.mock` to test code that uses `~"..."` without calling any API:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
require "mana/test"
|
|
331
|
+
|
|
332
|
+
RSpec.describe MyApp do
|
|
333
|
+
include Mana::TestHelpers
|
|
334
|
+
|
|
335
|
+
it "analyzes code" do
|
|
336
|
+
mock_prompt "analyze", bugs: ["XSS"], score: 8.5
|
|
337
|
+
|
|
338
|
+
result = MyApp.analyze("user_input")
|
|
339
|
+
expect(result[:bugs]).to include("XSS")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it "translates with dynamic response" do
|
|
343
|
+
mock_prompt(/translate.*to\s+\w+/) do |prompt|
|
|
344
|
+
{ output: prompt.include?("Chinese") ? "你好" : "hello" }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
expect(MyApp.translate("hi", "Chinese")).to eq("你好")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Block mode for inline tests:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
Mana.mock do
|
|
356
|
+
prompt "summarize", summary: "A brief overview"
|
|
357
|
+
|
|
358
|
+
text = "Long article..."
|
|
359
|
+
~"summarize <text> and store in <summary>"
|
|
360
|
+
puts summary # => "A brief overview"
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the stub to add.
|
|
365
|
+
|
|
220
366
|
### Nested prompts
|
|
221
367
|
|
|
222
368
|
Functions called by LLM can themselves contain `~"..."` prompts:
|
data/data/lang-rules.yml
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
languages:
|
|
3
|
+
javascript:
|
|
4
|
+
strong:
|
|
5
|
+
- "const "
|
|
6
|
+
- "let "
|
|
7
|
+
- "=> "
|
|
8
|
+
- "=== "
|
|
9
|
+
- "!== "
|
|
10
|
+
- "console.log"
|
|
11
|
+
- "console.error"
|
|
12
|
+
- ".filter("
|
|
13
|
+
- ".map("
|
|
14
|
+
- ".reduce("
|
|
15
|
+
- ".forEach("
|
|
16
|
+
- ".indexOf("
|
|
17
|
+
- ".push("
|
|
18
|
+
- "function "
|
|
19
|
+
- "async "
|
|
20
|
+
- "await "
|
|
21
|
+
- "require("
|
|
22
|
+
- "module.exports"
|
|
23
|
+
- "export "
|
|
24
|
+
- "import "
|
|
25
|
+
- "new Promise"
|
|
26
|
+
- "document."
|
|
27
|
+
- "window."
|
|
28
|
+
- "JSON.parse"
|
|
29
|
+
- "JSON.stringify"
|
|
30
|
+
- "typeof "
|
|
31
|
+
- "undefined"
|
|
32
|
+
- "null;"
|
|
33
|
+
weak:
|
|
34
|
+
- "var "
|
|
35
|
+
- "return "
|
|
36
|
+
- "true"
|
|
37
|
+
- "false"
|
|
38
|
+
- "null"
|
|
39
|
+
- "this."
|
|
40
|
+
anti:
|
|
41
|
+
- "let me"
|
|
42
|
+
- "let us"
|
|
43
|
+
- "let's"
|
|
44
|
+
- "const ant"
|
|
45
|
+
- "import ant"
|
|
46
|
+
- "import this"
|
|
47
|
+
patterns:
|
|
48
|
+
- "\\bfunction\\s+\\w+\\s*\\("
|
|
49
|
+
- "\\(\\s*\\w+\\s*\\)\\s*=>"
|
|
50
|
+
- "\\bclass\\s+\\w+\\s*\\{"
|
|
51
|
+
|
|
52
|
+
python:
|
|
53
|
+
strong:
|
|
54
|
+
- "elif "
|
|
55
|
+
- "print("
|
|
56
|
+
- "__init__"
|
|
57
|
+
- "__name__"
|
|
58
|
+
- "self."
|
|
59
|
+
- "import numpy"
|
|
60
|
+
- "import pandas"
|
|
61
|
+
- "lambda "
|
|
62
|
+
- "except "
|
|
63
|
+
- "finally:"
|
|
64
|
+
- "nonlocal "
|
|
65
|
+
weak:
|
|
66
|
+
- "def "
|
|
67
|
+
- "from "
|
|
68
|
+
- "yield "
|
|
69
|
+
- "raise "
|
|
70
|
+
- "assert "
|
|
71
|
+
- "global "
|
|
72
|
+
- "import "
|
|
73
|
+
- "return "
|
|
74
|
+
- "class "
|
|
75
|
+
- "for "
|
|
76
|
+
- "while "
|
|
77
|
+
- "if "
|
|
78
|
+
- "is not"
|
|
79
|
+
- "not in"
|
|
80
|
+
- "pass"
|
|
81
|
+
anti:
|
|
82
|
+
- "import this"
|
|
83
|
+
- "import that"
|
|
84
|
+
- "from here"
|
|
85
|
+
- "from there"
|
|
86
|
+
- "for example"
|
|
87
|
+
- "for instance"
|
|
88
|
+
- "while I"
|
|
89
|
+
- "while we"
|
|
90
|
+
- "if you"
|
|
91
|
+
- "if we"
|
|
92
|
+
- "with you"
|
|
93
|
+
- "with the"
|
|
94
|
+
- "with a "
|
|
95
|
+
- "with my"
|
|
96
|
+
- "and the"
|
|
97
|
+
- "and I"
|
|
98
|
+
- "and we"
|
|
99
|
+
- "or the"
|
|
100
|
+
- "or a "
|
|
101
|
+
- "or I"
|
|
102
|
+
- "or False"
|
|
103
|
+
- "True or"
|
|
104
|
+
- "as a "
|
|
105
|
+
- "as the"
|
|
106
|
+
- "as I"
|
|
107
|
+
- "pass the"
|
|
108
|
+
- "pass it"
|
|
109
|
+
- "pass on"
|
|
110
|
+
patterns:
|
|
111
|
+
- "\\[.+\\bfor\\b.+\\bin\\b.+\\]"
|
|
112
|
+
- "\\bdef\\s+\\w+\\s*\\("
|
|
113
|
+
- "\\bclass\\s+\\w+\\s*[:(]"
|
|
114
|
+
- "^\\s{4}"
|
|
115
|
+
|
|
116
|
+
ruby:
|
|
117
|
+
strong:
|
|
118
|
+
- "puts "
|
|
119
|
+
- "require "
|
|
120
|
+
- "require_relative"
|
|
121
|
+
- "attr_accessor"
|
|
122
|
+
- "attr_reader"
|
|
123
|
+
- "attr_writer"
|
|
124
|
+
- ".each "
|
|
125
|
+
- ".map "
|
|
126
|
+
- ".select "
|
|
127
|
+
- ".reject "
|
|
128
|
+
- "do |"
|
|
129
|
+
- "def "
|
|
130
|
+
- "module "
|
|
131
|
+
- "rescue "
|
|
132
|
+
- "ensure "
|
|
133
|
+
- "unless "
|
|
134
|
+
- "until "
|
|
135
|
+
- "elsif "
|
|
136
|
+
- "nil"
|
|
137
|
+
- "p "
|
|
138
|
+
- "pp "
|
|
139
|
+
- "Proc.new"
|
|
140
|
+
- "lambda {"
|
|
141
|
+
- "-> {"
|
|
142
|
+
weak:
|
|
143
|
+
- "end"
|
|
144
|
+
- "begin"
|
|
145
|
+
- "class "
|
|
146
|
+
- "return "
|
|
147
|
+
- "if "
|
|
148
|
+
- "for "
|
|
149
|
+
- "while "
|
|
150
|
+
anti:
|
|
151
|
+
- "the end"
|
|
152
|
+
- "in the end"
|
|
153
|
+
- "at the end"
|
|
154
|
+
- "begin with"
|
|
155
|
+
- "begin to"
|
|
156
|
+
patterns:
|
|
157
|
+
- "\\bdo\\s*\\|\\w+\\|"
|
|
158
|
+
- "\\bdef\\s+\\w+[?!]?"
|
|
159
|
+
- "\\bend\\b"
|
|
160
|
+
|
|
161
|
+
natural_language:
|
|
162
|
+
strong:
|
|
163
|
+
- "please "
|
|
164
|
+
- "analyze "
|
|
165
|
+
- "summarize "
|
|
166
|
+
- "translate "
|
|
167
|
+
- "explain "
|
|
168
|
+
- "find "
|
|
169
|
+
- "calculate "
|
|
170
|
+
- "generate "
|
|
171
|
+
- "create "
|
|
172
|
+
- "write "
|
|
173
|
+
- "describe "
|
|
174
|
+
- "compare "
|
|
175
|
+
- "store in <"
|
|
176
|
+
- "save in <"
|
|
177
|
+
- "put in <"
|
|
178
|
+
- ", store "
|
|
179
|
+
- ", save "
|
|
180
|
+
weak:
|
|
181
|
+
- "the "
|
|
182
|
+
- "a "
|
|
183
|
+
- "an "
|
|
184
|
+
- "is "
|
|
185
|
+
- "are "
|
|
186
|
+
- "was "
|
|
187
|
+
- "were "
|
|
188
|
+
- "what "
|
|
189
|
+
- "how "
|
|
190
|
+
- "why "
|
|
191
|
+
- "when "
|
|
192
|
+
- "where "
|
|
193
|
+
- "which "
|
|
194
|
+
anti: []
|
|
195
|
+
patterns:
|
|
196
|
+
- "<\\w+>"
|
|
@@ -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/compiler.rb
CHANGED
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
|