rasti-ai 2.0.1 → 3.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/.github/workflows/ci.yml +4 -20
- data/AGENTS.md +614 -0
- data/README.md +133 -25
- data/Rakefile +2 -0
- data/lib/rasti/ai/anthropic/assistant.rb +139 -0
- data/lib/rasti/ai/anthropic/client.rb +58 -0
- data/lib/rasti/ai/anthropic/roles.rb +12 -0
- data/lib/rasti/ai/assistant.rb +8 -15
- data/lib/rasti/ai/client.rb +16 -3
- data/lib/rasti/ai/gemini/assistant.rb +42 -25
- data/lib/rasti/ai/gemini/client.rb +14 -0
- data/lib/rasti/ai/mcp/client.rb +60 -9
- data/lib/rasti/ai/mcp/{errors.rb → constants.rb} +4 -1
- data/lib/rasti/ai/mcp/server.rb +42 -47
- data/lib/rasti/ai/mcp/tools_registry.rb +64 -0
- data/lib/rasti/ai/open_ai/assistant.rb +9 -17
- data/lib/rasti/ai/open_ai/client.rb +17 -2
- data/lib/rasti/ai/tool_serializer.rb +35 -62
- data/lib/rasti/ai/usage.rb +2 -1
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +10 -0
- data/rasti-ai.gemspec +4 -1
- data/spec/anthropic/assistant_spec.rb +349 -0
- data/spec/anthropic/client_spec.rb +203 -0
- data/spec/gemini/assistant_spec.rb +15 -66
- data/spec/gemini/client_spec.rb +50 -0
- data/spec/mcp/client_spec.rb +3 -1
- data/spec/mcp/server_spec.rb +195 -136
- data/spec/mcp/tools_registry_spec.rb +226 -0
- data/spec/minitest_helper.rb +29 -0
- data/spec/open_ai/assistant_spec.rb +20 -70
- data/spec/open_ai/client_spec.rb +53 -0
- data/spec/resources/anthropic/basic_request.json +1 -0
- data/spec/resources/anthropic/basic_response.json +20 -0
- data/spec/resources/anthropic/tool_request.json +1 -0
- data/spec/resources/anthropic/tool_response.json +22 -0
- data/spec/resources/gemini/basic_response.json +10 -3
- data/spec/tool_serializer_spec.rb +31 -6
- data/tasks/assistant.rake +94 -0
- metadata +46 -6
data/README.md
CHANGED
|
@@ -41,6 +41,10 @@ Rasti::AI.configure do |config|
|
|
|
41
41
|
config.gemini_api_key = 'AIza12345' # Default ENV['GEMINI_API_KEY']
|
|
42
42
|
config.gemini_default_model = 'gemini-2.0-flash' # Default ENV['GEMINI_DEFAULT_MODEL']
|
|
43
43
|
|
|
44
|
+
# Anthropic
|
|
45
|
+
config.anthropic_api_key = 'sk-ant-12345' # Default ENV['ANTHROPIC_API_KEY']
|
|
46
|
+
config.anthropic_default_model = 'claude-opus-4-5' # Default ENV['ANTHROPIC_DEFAULT_MODEL']
|
|
47
|
+
|
|
44
48
|
# Usage tracking
|
|
45
49
|
config.usage_tracker = ->(usage) { puts "#{usage.provider}: #{usage.input_tokens} in / #{usage.output_tokens} out" }
|
|
46
50
|
end
|
|
@@ -50,8 +54,9 @@ end
|
|
|
50
54
|
|
|
51
55
|
- **OpenAI** - `Rasti::AI::OpenAI::Assistant`
|
|
52
56
|
- **Gemini** - `Rasti::AI::Gemini::Assistant`
|
|
57
|
+
- **Anthropic** - `Rasti::AI::Anthropic::Assistant`
|
|
53
58
|
|
|
54
|
-
All providers share the same interface. The examples below use OpenAI, but apply equally to Gemini by replacing `OpenAI` with
|
|
59
|
+
All providers share the same interface. The examples below use OpenAI, but apply equally to Gemini or Anthropic by replacing `OpenAI` with the provider name.
|
|
55
60
|
|
|
56
61
|
### Assistant
|
|
57
62
|
|
|
@@ -110,6 +115,9 @@ Supported form attribute types:
|
|
|
110
115
|
- `Rasti::Types::Enum[:a, :b]` → `string (enum)`
|
|
111
116
|
- `Rasti::Types::Array[Type]` → `array`
|
|
112
117
|
- `Rasti::Types::Model[FormClass]` → nested `object`
|
|
118
|
+
- `Rasti::Types::Hash` → `object`
|
|
119
|
+
- Custom types registered via `Rasti::Model::Schema.register_type_serializer` or implementing `to_schema` are picked up automatically
|
|
120
|
+
- Unknown types → no constraints (empty schema, no crash)
|
|
113
121
|
|
|
114
122
|
#### Using tools with an assistant
|
|
115
123
|
```ruby
|
|
@@ -163,8 +171,27 @@ client = Rasti::AI::OpenAI::Client.new(
|
|
|
163
171
|
)
|
|
164
172
|
|
|
165
173
|
assistant = Rasti::AI::OpenAI::Assistant.new client: client
|
|
174
|
+
|
|
175
|
+
# Anthropic client
|
|
176
|
+
client = Rasti::AI::Anthropic::Client.new(
|
|
177
|
+
http_connect_timeout: 120,
|
|
178
|
+
http_read_timeout: 300 # Claude can be slow on long responses
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
assistant = Rasti::AI::Anthropic::Assistant.new client: client
|
|
166
182
|
```
|
|
167
183
|
|
|
184
|
+
### Thinking / extended reasoning
|
|
185
|
+
|
|
186
|
+
Some providers support extended reasoning ("thinking") to improve accuracy on complex tasks. Pass `thinking:` with a level of `'low'`, `'medium'`, or `'high'` when creating an assistant:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
assistant = Rasti::AI::Anthropic::Assistant.new thinking: 'high'
|
|
190
|
+
assistant.call 'Solve this step by step: ...'
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The level controls how much computation the model can spend reasoning before responding. Higher levels may improve answer quality at the cost of more tokens and latency. Not all models support thinking — check your provider's documentation.
|
|
194
|
+
|
|
168
195
|
### Usage tracking
|
|
169
196
|
|
|
170
197
|
Track token consumption across API calls (including tool calls):
|
|
@@ -173,7 +200,8 @@ Track token consumption across API calls (including tool calls):
|
|
|
173
200
|
tracked_usage = []
|
|
174
201
|
tracker = ->(usage) { tracked_usage << usage }
|
|
175
202
|
|
|
176
|
-
|
|
203
|
+
client = Rasti::AI::OpenAI::Client.new usage_tracker: tracker
|
|
204
|
+
assistant = Rasti::AI::OpenAI::Assistant.new client: client
|
|
177
205
|
assistant.call 'who is the best player'
|
|
178
206
|
|
|
179
207
|
usage = tracked_usage.first
|
|
@@ -183,6 +211,7 @@ usage.input_tokens # => 150
|
|
|
183
211
|
usage.output_tokens # => 42
|
|
184
212
|
usage.cached_tokens # => 0
|
|
185
213
|
usage.reasoning_tokens # => 0
|
|
214
|
+
usage.raw # => Raw usage payload from provider
|
|
186
215
|
```
|
|
187
216
|
|
|
188
217
|
The tracker can also be configured globally:
|
|
@@ -211,43 +240,113 @@ Rasti::AI::MCP::Server.configure do |config|
|
|
|
211
240
|
end
|
|
212
241
|
```
|
|
213
242
|
|
|
214
|
-
#####
|
|
243
|
+
##### Authentication
|
|
215
244
|
|
|
216
|
-
|
|
245
|
+
Use the `authenticate` block to control access to the MCP endpoint. The block receives the current `Rack::Request` and must return a truthy value to allow the request or a falsy value to reject it.
|
|
217
246
|
|
|
218
247
|
```ruby
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
'
|
|
248
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
249
|
+
config.authenticate do |request|
|
|
250
|
+
request.env['HTTP_AUTHORIZATION'] == "Bearer #{ENV['MCP_TOKEN']}"
|
|
222
251
|
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
223
254
|
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
When authentication fails the server returns HTTP 401 with a JSON-RPC error body. The check runs before the request body is read, so it covers all MCP methods including `initialize`.
|
|
256
|
+
|
|
257
|
+
The `authenticate` and `load_tools` blocks are independent — when authentication fails `load_tools` is never called.
|
|
258
|
+
|
|
259
|
+
##### Registering Tools
|
|
260
|
+
|
|
261
|
+
Tools are registered per-request via a `load_tools` block. The block receives a `ToolsRegistry` and the current `Rack::Request`, enabling context-aware tool instantiation (e.g. based on the authenticated user).
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
265
|
+
config.load_tools do |tools_registry, request|
|
|
266
|
+
user = User.find(request.session[:user_id])
|
|
267
|
+
|
|
268
|
+
# Form A: Rasti::AI::Tool instance — name, description and schema derived from the class
|
|
269
|
+
tools_registry.register tool: MyTool.new(user)
|
|
270
|
+
|
|
271
|
+
# Form B: tool instance with a custom name
|
|
272
|
+
tools_registry.register name: 'search', tool: SearchTool.new(user)
|
|
273
|
+
|
|
274
|
+
# Form C: tool instance with description or schema overrides
|
|
275
|
+
tools_registry.register(
|
|
276
|
+
tool: MyTool.new(user),
|
|
277
|
+
description: 'Contextual description for the LLM'
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Form D: existing Form class + block — schema from the Form, execution in the block
|
|
281
|
+
tools_registry.register(name: 'sum', description: 'Sum two numbers', form: SumTool::Form) do |args|
|
|
282
|
+
SumTool.new.call(args)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Form E: fully inline — raw JSON Schema, no class required
|
|
286
|
+
tools_registry.register(
|
|
287
|
+
name: 'report',
|
|
288
|
+
description: 'Generate a report',
|
|
289
|
+
input_schema: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
properties: {
|
|
292
|
+
title: {type: 'string'},
|
|
293
|
+
filters: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
category: {type: 'string', enum: ['sales', 'ops']},
|
|
297
|
+
date_range: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: {
|
|
300
|
+
from: {type: 'string', format: 'date'},
|
|
301
|
+
to: {type: 'string', format: 'date'}
|
|
302
|
+
},
|
|
303
|
+
required: ['from', 'to']
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
required: ['title']
|
|
309
|
+
}
|
|
310
|
+
) do |args|
|
|
311
|
+
user.generate_report(args['title'], args['filters'])
|
|
312
|
+
end
|
|
226
313
|
end
|
|
227
314
|
end
|
|
228
|
-
|
|
229
|
-
# Register tools
|
|
230
|
-
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
231
|
-
Rasti::AI::MCP::Server.register_tool SumTool.new
|
|
232
315
|
```
|
|
233
316
|
|
|
317
|
+
`tools_registry.register` accepts all keyword arguments as optional and combines them according to these precedence rules:
|
|
318
|
+
|
|
319
|
+
| Parameter | Purpose | Precedence |
|
|
320
|
+
|---|---|---|
|
|
321
|
+
| `name:` | Tool identifier | Explicit > derived from `tool.class` |
|
|
322
|
+
| `description:` | Description shown to the LLM | Explicit > `tool.class.description` |
|
|
323
|
+
| `input_schema:` | Raw JSON Schema hash for parameters | Explicit > `form:` > `tool.class.form` |
|
|
324
|
+
| `form:` | `Rasti::Form` subclass for schema | Used when no `input_schema:` |
|
|
325
|
+
| `tool:` | `Rasti::AI::Tool` instance | Provides defaults + executor |
|
|
326
|
+
| block | Executor called with args hash | Block > `tool.call` |
|
|
327
|
+
|
|
328
|
+
Block executors receive the arguments as a `Hash` with string keys and must return a `String`.
|
|
329
|
+
|
|
234
330
|
##### Using as Rack Middleware
|
|
235
331
|
|
|
236
332
|
```ruby
|
|
237
333
|
# In your config.ru
|
|
238
334
|
require 'rasti/ai'
|
|
239
335
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
336
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
337
|
+
config.load_tools do |tools_registry, request|
|
|
338
|
+
user = User.find(request.session[:user_id])
|
|
339
|
+
tools_registry.register tool: MyTool.new(user)
|
|
340
|
+
tools_registry.register tool: OtherTool.new(user)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
243
343
|
|
|
244
|
-
# Use as middleware
|
|
245
344
|
use Rasti::AI::MCP::Server
|
|
246
345
|
|
|
247
346
|
run YourApp
|
|
248
347
|
```
|
|
249
348
|
|
|
250
|
-
The server
|
|
349
|
+
The server handles POST requests to the configured path (`/mcp` by default) and forwards all other requests to the application. The `load_tools` block runs on every request, so tools are always fresh and scoped to the current request context.
|
|
251
350
|
|
|
252
351
|
##### Supported MCP Methods
|
|
253
352
|
|
|
@@ -262,10 +361,8 @@ The MCP Client allows you to communicate with MCP servers.
|
|
|
262
361
|
##### Basic Usage
|
|
263
362
|
|
|
264
363
|
```ruby
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
url: 'https://mcp.server.ai/mcp'
|
|
268
|
-
)
|
|
364
|
+
client = Rasti::AI::MCP::Client.new url: 'https://mcp.server.ai/mcp'
|
|
365
|
+
|
|
269
366
|
|
|
270
367
|
# List available tools
|
|
271
368
|
tools = client.list_tools
|
|
@@ -315,9 +412,8 @@ client = Rasti::AI::MCP::Client.new(
|
|
|
315
412
|
You can use MCP clients as tools for any assistant:
|
|
316
413
|
|
|
317
414
|
```ruby
|
|
318
|
-
mcp_client = Rasti::AI::MCP::Client.new
|
|
319
|
-
|
|
320
|
-
)
|
|
415
|
+
mcp_client = Rasti::AI::MCP::Client.new url: 'https://mcp.server.ai/mcp'
|
|
416
|
+
|
|
321
417
|
|
|
322
418
|
assistant = Rasti::AI::OpenAI::Assistant.new(
|
|
323
419
|
mcp_servers: {my_mcp: mcp_client}
|
|
@@ -327,6 +423,18 @@ assistant = Rasti::AI::OpenAI::Assistant.new(
|
|
|
327
423
|
assistant.call 'What is 5 plus 3?'
|
|
328
424
|
```
|
|
329
425
|
|
|
426
|
+
## Try it out
|
|
427
|
+
|
|
428
|
+
The gem includes interactive chat tasks wired to the [Pipeworx](https://pipeworx.io) public weather MCP server (no auth required):
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
OPENAI_API_KEY=sk-... rake assistant:openai
|
|
432
|
+
GEMINI_API_KEY=AIza... rake assistant:gemini
|
|
433
|
+
ANTHROPIC_API_KEY=sk-... rake assistant:anthropic
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Type your message and press Enter. Type `exit` or `Ctrl+C` to quit.
|
|
437
|
+
|
|
330
438
|
## Contributing
|
|
331
439
|
|
|
332
440
|
Bug reports and pull requests are welcome on GitHub at https://github.com/gabynaiman/rasti-ai.
|
data/Rakefile
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module Anthropic
|
|
4
|
+
class Assistant < Rasti::AI::Assistant
|
|
5
|
+
|
|
6
|
+
ALLOWED_SCHEMA_FIELDS = %w[type description properties required enum items format nullable anyOf].freeze
|
|
7
|
+
|
|
8
|
+
THINKING_LEVELS = {
|
|
9
|
+
'low' => {type: 'enabled', budget_tokens: 1_024}.freeze,
|
|
10
|
+
'medium' => {type: 'enabled', budget_tokens: 8_000}.freeze,
|
|
11
|
+
'high' => {type: 'enabled', budget_tokens: 16_000}.freeze
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def build_default_client
|
|
17
|
+
Client.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_user_message(prompt)
|
|
21
|
+
{role: Roles::USER, content: prompt}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_assistant_message(content)
|
|
25
|
+
{role: Roles::ASSISTANT, content: content}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_assistant_tool_calls_message(response)
|
|
29
|
+
{role: Roles::ASSISTANT, content: response['content']}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_tool_result_message(tool_call, name, result)
|
|
33
|
+
{
|
|
34
|
+
role: Roles::USER,
|
|
35
|
+
content: [{
|
|
36
|
+
type: 'tool_result',
|
|
37
|
+
tool_use_id: tool_call['id'],
|
|
38
|
+
content: result
|
|
39
|
+
}]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def request_completion
|
|
44
|
+
all_tools = serialized_tools.dup
|
|
45
|
+
all_tools << structured_output_tool if json_schema
|
|
46
|
+
|
|
47
|
+
tc = if json_schema
|
|
48
|
+
{type: 'tool', name: 'structured_output'}
|
|
49
|
+
elsif all_tools.any?
|
|
50
|
+
{type: 'auto'}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
client.messages(
|
|
54
|
+
messages: messages,
|
|
55
|
+
model: model,
|
|
56
|
+
system: state.context,
|
|
57
|
+
tools: all_tools,
|
|
58
|
+
tool_choice: tc,
|
|
59
|
+
thinking: thinking_config
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def thinking_config
|
|
64
|
+
THINKING_LEVELS[thinking]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_tool_calls(response)
|
|
68
|
+
content = response['content'] || []
|
|
69
|
+
content.select { |block| block['type'] == 'tool_use' && block['name'] != 'structured_output' }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_content(response)
|
|
73
|
+
content = response['content'] || []
|
|
74
|
+
|
|
75
|
+
if json_schema
|
|
76
|
+
structured = content.find { |block| block['type'] == 'tool_use' && block['name'] == 'structured_output' }
|
|
77
|
+
return JSON.dump(structured['input']) if structured
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
text_block = content.find { |block| block['type'] == 'text' }
|
|
81
|
+
text_block&.[]('text')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def finished?(response)
|
|
85
|
+
!response['stop_reason'].nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def extract_tool_call_info(tool_call)
|
|
89
|
+
[tool_call['name'], tool_call['input'] || {}]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wrap_tool_serialization(raw)
|
|
93
|
+
schema = raw[:inputSchema] || raw['inputSchema']
|
|
94
|
+
|
|
95
|
+
result = {
|
|
96
|
+
name: raw[:name] || raw['name'],
|
|
97
|
+
description: raw[:description] || raw['description'] || raw[:title] || raw['title']
|
|
98
|
+
}
|
|
99
|
+
result[:input_schema] = sanitize_schema(schema) if schema
|
|
100
|
+
result.reject { |_, v| v.nil? }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sanitize_schema(schema)
|
|
104
|
+
return schema unless schema.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
schema.each_with_object({}) do |(key, value), acc|
|
|
107
|
+
next unless ALLOWED_SCHEMA_FIELDS.include?(key.to_s)
|
|
108
|
+
acc[key] = case key.to_s
|
|
109
|
+
when 'properties'
|
|
110
|
+
value.each_with_object({}) { |(k, v), h| h[k] = sanitize_schema(v) }
|
|
111
|
+
when 'items'
|
|
112
|
+
sanitize_schema(value)
|
|
113
|
+
when 'anyOf'
|
|
114
|
+
value.map { |item| sanitize_schema(item) }
|
|
115
|
+
else
|
|
116
|
+
value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def extract_tool_name(wrapped)
|
|
122
|
+
wrapped[:name] || wrapped['name']
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def structured_output_tool
|
|
126
|
+
{
|
|
127
|
+
name: 'structured_output',
|
|
128
|
+
description: 'Return the structured response',
|
|
129
|
+
input_schema: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: json_schema
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module Anthropic
|
|
4
|
+
class Client < Rasti::AI::Client
|
|
5
|
+
|
|
6
|
+
ANTHROPIC_VERSION = '2023-06-01'.freeze
|
|
7
|
+
DEFAULT_MAX_TOKENS = 4096
|
|
8
|
+
|
|
9
|
+
def messages(messages:, model:nil, system:nil, tools:[], tool_choice:nil, max_tokens:nil, thinking:nil)
|
|
10
|
+
body = {
|
|
11
|
+
model: model || Rasti::AI.anthropic_default_model,
|
|
12
|
+
max_tokens: max_tokens || DEFAULT_MAX_TOKENS,
|
|
13
|
+
messages: messages
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body[:thinking] = thinking if thinking
|
|
17
|
+
body[:system] = system if system
|
|
18
|
+
body[:tools] = tools unless tools.empty?
|
|
19
|
+
body[:tool_choice] = tool_choice if tool_choice
|
|
20
|
+
|
|
21
|
+
post '/messages', body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse_usage(response)
|
|
27
|
+
usage = response['usage']
|
|
28
|
+
return unless usage
|
|
29
|
+
Usage.new(
|
|
30
|
+
provider: 'anthropic',
|
|
31
|
+
model: response['model'],
|
|
32
|
+
input_tokens: usage['input_tokens'],
|
|
33
|
+
output_tokens: usage['output_tokens'],
|
|
34
|
+
cached_tokens: usage['cache_read_input_tokens'] || 0,
|
|
35
|
+
reasoning_tokens: 0,
|
|
36
|
+
raw: usage
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def default_api_key
|
|
41
|
+
Rasti::AI.anthropic_api_key
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def base_url
|
|
45
|
+
'https://api.anthropic.com/v1'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_request(uri)
|
|
49
|
+
request = super
|
|
50
|
+
request['x-api-key'] = api_key
|
|
51
|
+
request['anthropic-version'] = ANTHROPIC_VERSION
|
|
52
|
+
request
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/rasti/ai/assistant.rb
CHANGED
|
@@ -2,17 +2,21 @@ module Rasti
|
|
|
2
2
|
module AI
|
|
3
3
|
class Assistant
|
|
4
4
|
|
|
5
|
-
attr_reader :state
|
|
5
|
+
attr_reader :state, :model, :thinking
|
|
6
|
+
|
|
7
|
+
VALID_THINKING_LEVELS = %w[low medium high].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(client:nil, json_schema:nil, state:nil, model:nil, thinking:nil, tools:[], mcp_servers:{}, logger:nil)
|
|
10
|
+
raise ArgumentError, "Invalid thinking level '#{thinking}'. Valid: #{VALID_THINKING_LEVELS.join(', ')}" if thinking && !VALID_THINKING_LEVELS.include?(thinking)
|
|
6
11
|
|
|
7
|
-
def initialize(client:nil, json_schema:nil, state:nil, model:nil, tools:[], mcp_servers:{}, logger:nil, usage_tracker:nil)
|
|
8
12
|
@client = client || build_default_client
|
|
9
13
|
@json_schema = json_schema
|
|
10
14
|
@state = state || AssistantState.new
|
|
11
15
|
@model = model
|
|
16
|
+
@thinking = thinking
|
|
12
17
|
@tools = {}
|
|
13
18
|
@serialized_tools = []
|
|
14
19
|
@logger = logger || Rasti::AI.logger
|
|
15
|
-
@usage_tracker = usage_tracker || Rasti::AI.usage_tracker
|
|
16
20
|
|
|
17
21
|
register_tools(tools)
|
|
18
22
|
register_mcp_servers(mcp_servers)
|
|
@@ -23,7 +27,6 @@ module Rasti
|
|
|
23
27
|
|
|
24
28
|
loop do
|
|
25
29
|
response = request_completion
|
|
26
|
-
track_usage response
|
|
27
30
|
|
|
28
31
|
tool_calls = parse_tool_calls(response)
|
|
29
32
|
|
|
@@ -47,18 +50,12 @@ module Rasti
|
|
|
47
50
|
|
|
48
51
|
private
|
|
49
52
|
|
|
50
|
-
attr_reader :client, :json_schema, :
|
|
53
|
+
attr_reader :client, :json_schema, :tools, :serialized_tools, :logger
|
|
51
54
|
|
|
52
55
|
def messages
|
|
53
56
|
state.messages
|
|
54
57
|
end
|
|
55
58
|
|
|
56
|
-
def track_usage(response)
|
|
57
|
-
return unless usage_tracker
|
|
58
|
-
usage = parse_usage response
|
|
59
|
-
usage_tracker.call usage if usage
|
|
60
|
-
end
|
|
61
|
-
|
|
62
59
|
# --- Shared behavior ---
|
|
63
60
|
|
|
64
61
|
def register_tools(tools)
|
|
@@ -140,10 +137,6 @@ module Rasti
|
|
|
140
137
|
raise NotImplementedError
|
|
141
138
|
end
|
|
142
139
|
|
|
143
|
-
def parse_usage(response)
|
|
144
|
-
raise NotImplementedError
|
|
145
|
-
end
|
|
146
|
-
|
|
147
140
|
def extract_tool_call_info(tool_call)
|
|
148
141
|
raise NotImplementedError
|
|
149
142
|
end
|
data/lib/rasti/ai/client.rb
CHANGED
|
@@ -4,17 +4,28 @@ module Rasti
|
|
|
4
4
|
|
|
5
5
|
RETRYABLE_STATUS_CODES = [502, 503, 504].freeze
|
|
6
6
|
|
|
7
|
-
def initialize(api_key:nil, logger:nil, http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil)
|
|
7
|
+
def initialize(api_key:nil, logger:nil, http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil, usage_tracker:nil)
|
|
8
8
|
@api_key = api_key || default_api_key
|
|
9
9
|
@logger = logger || Rasti::AI.logger
|
|
10
10
|
@http_connect_timeout = http_connect_timeout || Rasti::AI.http_connect_timeout
|
|
11
11
|
@http_read_timeout = http_read_timeout || Rasti::AI.http_read_timeout
|
|
12
12
|
@http_max_retries = http_max_retries || Rasti::AI.http_max_retries
|
|
13
|
+
@usage_tracker = usage_tracker || Rasti::AI.usage_tracker
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
17
|
-
attr_reader :api_key, :logger, :http_connect_timeout, :http_read_timeout, :http_max_retries
|
|
18
|
+
attr_reader :api_key, :logger, :http_connect_timeout, :http_read_timeout, :http_max_retries, :usage_tracker
|
|
19
|
+
|
|
20
|
+
def track_usage(response)
|
|
21
|
+
return unless usage_tracker
|
|
22
|
+
usage = parse_usage response
|
|
23
|
+
usage_tracker.call usage if usage
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_usage(response)
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
18
29
|
|
|
19
30
|
def default_api_key
|
|
20
31
|
raise NotImplementedError
|
|
@@ -63,7 +74,9 @@ module Rasti
|
|
|
63
74
|
raise Errors::RequestFail.new(url, body, response)
|
|
64
75
|
end
|
|
65
76
|
|
|
66
|
-
JSON.parse response.body
|
|
77
|
+
parsed_response = JSON.parse response.body
|
|
78
|
+
track_usage parsed_response
|
|
79
|
+
parsed_response
|
|
67
80
|
|
|
68
81
|
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout, Errors::RequestFail => e
|
|
69
82
|
if retry_count < max_retries
|
|
@@ -3,6 +3,14 @@ module Rasti
|
|
|
3
3
|
module Gemini
|
|
4
4
|
class Assistant < Rasti::AI::Assistant
|
|
5
5
|
|
|
6
|
+
ALLOWED_SCHEMA_FIELDS = %w[type description properties required enum items format nullable anyOf].freeze
|
|
7
|
+
|
|
8
|
+
THINKING_LEVELS = {
|
|
9
|
+
'low' => {thinking_budget: 1_024}.freeze,
|
|
10
|
+
'medium' => {thinking_budget: 8_192}.freeze,
|
|
11
|
+
'high' => {thinking_budget: 24_576}.freeze
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
6
14
|
private
|
|
7
15
|
|
|
8
16
|
def build_default_client
|
|
@@ -60,32 +68,38 @@ module Rasti
|
|
|
60
68
|
!response.dig('candidates', 0, 'finishReason').nil?
|
|
61
69
|
end
|
|
62
70
|
|
|
63
|
-
def parse_usage(response)
|
|
64
|
-
usage = response['usageMetadata']
|
|
65
|
-
return unless usage
|
|
66
|
-
Usage.new(
|
|
67
|
-
provider: :gemini,
|
|
68
|
-
model: response['modelVersion'],
|
|
69
|
-
input_tokens: usage['promptTokenCount'],
|
|
70
|
-
output_tokens: usage['candidatesTokenCount'],
|
|
71
|
-
cached_tokens: usage['cachedContentTokenCount'] || 0,
|
|
72
|
-
reasoning_tokens: usage['thoughtsTokenCount'] || 0
|
|
73
|
-
)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
71
|
def extract_tool_call_info(tool_call)
|
|
77
72
|
fc = tool_call['functionCall']
|
|
78
73
|
[fc['name'], fc['args'] || {}]
|
|
79
74
|
end
|
|
80
75
|
|
|
81
76
|
def wrap_tool_serialization(raw)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
schema = raw[:inputSchema] || raw['inputSchema']
|
|
78
|
+
|
|
79
|
+
result = {
|
|
80
|
+
name: raw[:name] || raw['name'],
|
|
81
|
+
description: raw[:description] || raw['description'] || raw[:title] || raw['title']
|
|
82
|
+
}
|
|
83
|
+
result[:parameters] = sanitize_schema(schema) if schema
|
|
84
|
+
result.reject { |_, v| v.nil? }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sanitize_schema(schema)
|
|
88
|
+
return schema unless schema.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
schema.each_with_object({}) do |(key, value), acc|
|
|
91
|
+
next unless ALLOWED_SCHEMA_FIELDS.include?(key.to_s)
|
|
92
|
+
acc[key] = case key.to_s
|
|
93
|
+
when 'properties'
|
|
94
|
+
value.each_with_object({}) { |(k, v), h| h[k] = sanitize_schema(v) }
|
|
95
|
+
when 'items'
|
|
96
|
+
sanitize_schema(value)
|
|
97
|
+
when 'anyOf'
|
|
98
|
+
value.map { |item| sanitize_schema(item) }
|
|
99
|
+
else
|
|
100
|
+
value
|
|
101
|
+
end
|
|
87
102
|
end
|
|
88
|
-
result
|
|
89
103
|
end
|
|
90
104
|
|
|
91
105
|
def extract_tool_name(wrapped)
|
|
@@ -97,13 +111,16 @@ module Rasti
|
|
|
97
111
|
[{function_declarations: serialized_tools}]
|
|
98
112
|
end
|
|
99
113
|
|
|
100
|
-
def
|
|
101
|
-
|
|
114
|
+
def thinking_config
|
|
115
|
+
THINKING_LEVELS[thinking]
|
|
116
|
+
end
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
def generation_config
|
|
119
|
+
config = {}
|
|
120
|
+
config[:thinking_config] = thinking_config if thinking
|
|
121
|
+
config[:response_mime_type] = 'application/json' if json_schema
|
|
122
|
+
config[:response_schema] = json_schema if json_schema
|
|
123
|
+
config.empty? ? nil : config
|
|
107
124
|
end
|
|
108
125
|
|
|
109
126
|
end
|
|
@@ -17,6 +17,20 @@ module Rasti
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
+
def parse_usage(response)
|
|
21
|
+
usage = response['usageMetadata']
|
|
22
|
+
return unless usage
|
|
23
|
+
Usage.new(
|
|
24
|
+
provider: 'gemini',
|
|
25
|
+
model: response['modelVersion'],
|
|
26
|
+
input_tokens: usage['promptTokenCount'],
|
|
27
|
+
output_tokens: usage['candidatesTokenCount'],
|
|
28
|
+
cached_tokens: usage['cachedContentTokenCount'] || 0,
|
|
29
|
+
reasoning_tokens: usage['thoughtsTokenCount'] || 0,
|
|
30
|
+
raw: usage
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
20
34
|
def default_api_key
|
|
21
35
|
Rasti::AI.gemini_api_key
|
|
22
36
|
end
|