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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -20
  3. data/AGENTS.md +614 -0
  4. data/README.md +133 -25
  5. data/Rakefile +2 -0
  6. data/lib/rasti/ai/anthropic/assistant.rb +139 -0
  7. data/lib/rasti/ai/anthropic/client.rb +58 -0
  8. data/lib/rasti/ai/anthropic/roles.rb +12 -0
  9. data/lib/rasti/ai/assistant.rb +8 -15
  10. data/lib/rasti/ai/client.rb +16 -3
  11. data/lib/rasti/ai/gemini/assistant.rb +42 -25
  12. data/lib/rasti/ai/gemini/client.rb +14 -0
  13. data/lib/rasti/ai/mcp/client.rb +60 -9
  14. data/lib/rasti/ai/mcp/{errors.rb → constants.rb} +4 -1
  15. data/lib/rasti/ai/mcp/server.rb +42 -47
  16. data/lib/rasti/ai/mcp/tools_registry.rb +64 -0
  17. data/lib/rasti/ai/open_ai/assistant.rb +9 -17
  18. data/lib/rasti/ai/open_ai/client.rb +17 -2
  19. data/lib/rasti/ai/tool_serializer.rb +35 -62
  20. data/lib/rasti/ai/usage.rb +2 -1
  21. data/lib/rasti/ai/version.rb +1 -1
  22. data/lib/rasti/ai.rb +10 -0
  23. data/rasti-ai.gemspec +4 -1
  24. data/spec/anthropic/assistant_spec.rb +349 -0
  25. data/spec/anthropic/client_spec.rb +203 -0
  26. data/spec/gemini/assistant_spec.rb +15 -66
  27. data/spec/gemini/client_spec.rb +50 -0
  28. data/spec/mcp/client_spec.rb +3 -1
  29. data/spec/mcp/server_spec.rb +195 -136
  30. data/spec/mcp/tools_registry_spec.rb +226 -0
  31. data/spec/minitest_helper.rb +29 -0
  32. data/spec/open_ai/assistant_spec.rb +20 -70
  33. data/spec/open_ai/client_spec.rb +53 -0
  34. data/spec/resources/anthropic/basic_request.json +1 -0
  35. data/spec/resources/anthropic/basic_response.json +20 -0
  36. data/spec/resources/anthropic/tool_request.json +1 -0
  37. data/spec/resources/anthropic/tool_response.json +22 -0
  38. data/spec/resources/gemini/basic_response.json +10 -3
  39. data/spec/tool_serializer_spec.rb +31 -6
  40. data/tasks/assistant.rake +94 -0
  41. 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 `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,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, :model, :tools, :serialized_tools, :logger, :usage_tracker
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
@@ -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
- result = raw.dup
83
- if result.key?(:inputSchema)
84
- result[:parameters] = result.delete(:inputSchema)
85
- elsif result.key?('inputSchema')
86
- 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
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 generation_config
101
- return nil if json_schema.nil?
114
+ def thinking_config
115
+ THINKING_LEVELS[thinking]
116
+ end
102
117
 
103
- {
104
- response_mime_type: 'application/json',
105
- response_schema: json_schema
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