rasti-ai 2.0.2 → 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.
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 `Gemini`.
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
- assistant = Rasti::AI::OpenAI::Assistant.new usage_tracker: tracker
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
- ##### Registering Tools
243
+ ##### Authentication
215
244
 
216
- Tools must inherit from `Rasti::AI::Tool` and can be registered with the server:
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
- class HelloWorldTool < Rasti::AI::Tool
220
- def self.description
221
- 'Returns a hello world message'
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
- def execute(form)
225
- {text: 'Hello world'}
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
- # Register your tools
241
- Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
242
- Rasti::AI::MCP::Server.register_tool SumTool.new
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 will handle POST requests to the configured path (`/mcp` by default) and pass all other requests to your application.
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
- # Create a client
266
- client = Rasti::AI::MCP::Client.new(
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
- url: 'https://mcp.server.ai/mcp'
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
@@ -1,6 +1,8 @@
1
1
  require 'bundler/gem_tasks'
2
2
  require 'rake/testtask'
3
3
 
4
+ FileList['tasks/**/*.rake'].each { |f| import f }
5
+
4
6
  Rake::TestTask.new(:spec) do |t|
5
7
  t.libs << 'spec'
6
8
  t.libs << 'lib'
@@ -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
@@ -0,0 +1,12 @@
1
+ module Rasti
2
+ module AI
3
+ module Anthropic
4
+ module Roles
5
+
6
+ USER = 'user'.freeze
7
+ ASSISTANT = 'assistant'.freeze
8
+
9
+ end
10
+ end
11
+ end
12
+ end
@@ -2,13 +2,18 @@ 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)
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
@@ -45,7 +50,7 @@ module Rasti
45
50
 
46
51
  private
47
52
 
48
- attr_reader :client, :json_schema, :model, :tools, :serialized_tools, :logger
53
+ attr_reader :client, :json_schema, :tools, :serialized_tools, :logger
49
54
 
50
55
  def messages
51
56
  state.messages
@@ -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
@@ -66,13 +74,32 @@ module Rasti
66
74
  end
67
75
 
68
76
  def wrap_tool_serialization(raw)
69
- result = raw.dup
70
- if result.key?(:inputSchema)
71
- result[:parameters] = result.delete(:inputSchema)
72
- elsif result.key?('inputSchema')
73
- result['parameters'] = result.delete('inputSchema')
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
74
102
  end
75
- result
76
103
  end
77
104
 
78
105
  def extract_tool_name(wrapped)
@@ -84,13 +111,16 @@ module Rasti
84
111
  [{function_declarations: serialized_tools}]
85
112
  end
86
113
 
87
- def generation_config
88
- return nil if json_schema.nil?
114
+ def thinking_config
115
+ THINKING_LEVELS[thinking]
116
+ end
89
117
 
90
- {
91
- response_mime_type: 'application/json',
92
- response_schema: json_schema
93
- }
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
94
124
  end
95
125
 
96
126
  end