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.
@@ -0,0 +1,349 @@
1
+ require 'minitest_helper'
2
+
3
+ describe Rasti::AI::Anthropic::Assistant do
4
+
5
+ let(:api_url) { 'https://api.anthropic.com/v1/messages' }
6
+
7
+ let(:question) { 'How many goals has Messi scored for Barca?' }
8
+
9
+ let(:answer) { 'Lionel Messi scored 672 goals in 778 official matches for FC Barcelona.' }
10
+
11
+ def stub_anthropic_messages(question:, answer:, model:nil, json_schema:nil)
12
+ model ||= Rasti::AI.anthropic_default_model
13
+
14
+ body = read_json_resource('anthropic/basic_request.json', model: model, prompt: question)
15
+
16
+ if json_schema
17
+ body['tools'] = [{
18
+ 'name' => 'structured_output',
19
+ 'description' => 'Return the structured response',
20
+ 'input_schema' => {'type' => 'object', 'properties' => json_schema}
21
+ }]
22
+ body['tool_choice'] = {'type' => 'tool', 'name' => 'structured_output'}
23
+
24
+ stub_request(:post, api_url)
25
+ .with(body: JSON.dump(body))
26
+ .to_return(body: read_resource('anthropic/tool_response.json', name: 'structured_output', arguments: json_schema))
27
+ else
28
+ stub_request(:post, api_url)
29
+ .with(body: JSON.dump(body))
30
+ .to_return(body: read_resource('anthropic/basic_response.json', content: answer))
31
+ end
32
+ end
33
+
34
+ it 'Default' do
35
+ stub_anthropic_messages question: question, answer: answer
36
+
37
+ assistant = Rasti::AI::Anthropic::Assistant.new
38
+
39
+ response = assistant.call question
40
+
41
+ assert_equal answer, response
42
+ end
43
+
44
+ describe 'Customized' do
45
+
46
+ it 'Client' do
47
+ client_arguments = [
48
+ {
49
+ model: nil,
50
+ system: nil,
51
+ tools: [],
52
+ tool_choice: nil,
53
+ thinking: nil,
54
+ messages: [
55
+ {
56
+ role: Rasti::AI::Anthropic::Roles::USER,
57
+ content: question
58
+ }
59
+ ]
60
+ }
61
+ ]
62
+
63
+ client_response = read_json_resource 'anthropic/basic_response.json', content: answer
64
+
65
+ client = Minitest::Mock.new
66
+ client.expect :messages, client_response, client_arguments
67
+
68
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client
69
+
70
+ response = assistant.call question
71
+
72
+ assert_equal answer, response
73
+
74
+ client.verify
75
+ end
76
+
77
+ it 'State' do
78
+ context = 'Act as sports journalist'
79
+ state = Rasti::AI::AssistantState.new context: context
80
+
81
+ request_body = {
82
+ model: Rasti::AI.anthropic_default_model,
83
+ max_tokens: 4096,
84
+ messages: [
85
+ {
86
+ role: Rasti::AI::Anthropic::Roles::USER,
87
+ content: question
88
+ }
89
+ ],
90
+ system: context
91
+ }
92
+
93
+ stub_request(:post, api_url)
94
+ .with(body: JSON.dump(request_body))
95
+ .to_return(body: read_resource('anthropic/basic_response.json', content: answer))
96
+
97
+ assistant = Rasti::AI::Anthropic::Assistant.new state: state
98
+
99
+ response = assistant.call question
100
+
101
+ expected_assistant_message = {
102
+ role: Rasti::AI::Anthropic::Roles::ASSISTANT,
103
+ content: answer
104
+ }
105
+
106
+ assert_equal answer, response
107
+ assert_equal 2, state.messages.count
108
+ assert_equal expected_assistant_message, state.messages.last
109
+ end
110
+
111
+ it 'Model' do
112
+ model = SecureRandom.uuid
113
+
114
+ stub_anthropic_messages question: question, answer: answer, model: model
115
+
116
+ assistant = Rasti::AI::Anthropic::Assistant.new model: model
117
+
118
+ response = assistant.call question
119
+
120
+ assert_equal answer, response
121
+ end
122
+
123
+ it 'Thinking' do
124
+ body = read_json_resource('anthropic/basic_request.json', model: Rasti::AI.anthropic_default_model, prompt: question)
125
+ body['thinking'] = {'type' => 'enabled', 'budget_tokens' => 8_000}
126
+
127
+ stub_request(:post, api_url)
128
+ .with(body: JSON.dump(body))
129
+ .to_return(body: read_resource('anthropic/basic_response.json', content: answer))
130
+
131
+ assistant = Rasti::AI::Anthropic::Assistant.new thinking: 'medium'
132
+
133
+ response = assistant.call question
134
+
135
+ assert_equal answer, response
136
+ end
137
+
138
+ it 'JSON Schema' do
139
+ json_schema = {'answer' => 'Response answer'}
140
+
141
+ stub_anthropic_messages question: question, answer: answer, json_schema: json_schema
142
+
143
+ assistant = Rasti::AI::Anthropic::Assistant.new json_schema: json_schema
144
+
145
+ response = assistant.call question
146
+
147
+ assert_equal 'Response answer', JSON.parse(response)['answer']
148
+ end
149
+
150
+ end
151
+
152
+
153
+
154
+ describe 'Tools' do
155
+
156
+ let(:client) { Minitest::Mock.new }
157
+
158
+ let(:tool_response) do
159
+ read_json_resource(
160
+ 'anthropic/tool_response.json',
161
+ name: 'goals_by_player',
162
+ arguments: {
163
+ player: 'Lionel Messi',
164
+ team: 'Barcelona'
165
+ }
166
+ )
167
+ end
168
+
169
+ let(:tool_result) { '672' }
170
+
171
+ let(:error_message) { 'There was an error using a tool' }
172
+
173
+ def basic_response(content)
174
+ read_json_resource 'anthropic/basic_response.json', content: content
175
+ end
176
+
177
+ def stub_client_request(role:, content:, response:, tools:[])
178
+ serialized_tools = tools.map do |tool|
179
+ raw = Rasti::AI::ToolSerializer.serialize(tool.class)
180
+ result = raw.dup
181
+ if result.key?(:inputSchema)
182
+ result[:input_schema] = result.delete(:inputSchema)
183
+ end
184
+ result
185
+ end
186
+
187
+ client.expect :messages, response do |params|
188
+ last_message = params[:messages].last
189
+ last_message[:role] == role &&
190
+ last_message[:content] == content &&
191
+ params[:tools] == serialized_tools
192
+ end
193
+ end
194
+
195
+ it 'Call function' do
196
+ tool = GoalsByPlayer.new
197
+
198
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
199
+ content: question,
200
+ tools: [tool],
201
+ response: tool_response
202
+
203
+ expected_tool_result_content = [{
204
+ type: 'tool_result',
205
+ tool_use_id: 'toolu_01A09q90qw90lq917835lq9',
206
+ content: tool_result
207
+ }]
208
+
209
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
210
+ content: expected_tool_result_content,
211
+ tools: [tool],
212
+ response: basic_response(answer)
213
+
214
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client, tools: [tool]
215
+
216
+ response = assistant.call question
217
+
218
+ assert_equal answer, response
219
+
220
+ client.verify
221
+ end
222
+
223
+ it 'Tool failure' do
224
+ tool = GoalsByPlayer.new
225
+ tool.define_singleton_method :call do |*args|
226
+ raise 'Broken tool'
227
+ end
228
+
229
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
230
+ content: question,
231
+ tools: [tool],
232
+ response: tool_response
233
+
234
+ expected_tool_result_content = [{
235
+ type: 'tool_result',
236
+ tool_use_id: 'toolu_01A09q90qw90lq917835lq9',
237
+ content: 'Error: Broken tool'
238
+ }]
239
+
240
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
241
+ content: expected_tool_result_content,
242
+ tools: [tool],
243
+ response: basic_response(error_message)
244
+
245
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client, tools: [tool]
246
+
247
+ response = assistant.call question
248
+
249
+ assert_equal error_message, response
250
+
251
+ client.verify
252
+ end
253
+
254
+ it 'Undefined tool' do
255
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
256
+ content: question,
257
+ response: tool_response
258
+
259
+ expected_tool_result_content = [{
260
+ type: 'tool_result',
261
+ tool_use_id: 'toolu_01A09q90qw90lq917835lq9',
262
+ content: 'Error: Undefined tool goals_by_player'
263
+ }]
264
+
265
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
266
+ content: expected_tool_result_content,
267
+ response: basic_response(error_message)
268
+
269
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client, tools: []
270
+
271
+ response = assistant.call question
272
+
273
+ assert_equal error_message, response
274
+
275
+ client.verify
276
+ end
277
+
278
+ it 'Cached result' do
279
+ mock = Minitest::Mock.new
280
+ mock.expect :call, tool_result, [{'player' => 'Lionel Messi', 'team' => 'Barcelona'}]
281
+
282
+ tool = GoalsByPlayer.new
283
+ tool.define_singleton_method :call do |*args|
284
+ mock.call(*args)
285
+ end
286
+
287
+ expected_tool_result_content = [{
288
+ type: 'tool_result',
289
+ tool_use_id: 'toolu_01A09q90qw90lq917835lq9',
290
+ content: tool_result
291
+ }]
292
+
293
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client, tools: [tool]
294
+
295
+ 5.times do
296
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
297
+ content: question,
298
+ tools: [tool],
299
+ response: tool_response
300
+
301
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
302
+ content: expected_tool_result_content,
303
+ tools: [tool],
304
+ response: basic_response(answer)
305
+
306
+ response = assistant.call question
307
+
308
+ assert_equal answer, response
309
+ end
310
+
311
+ client.verify
312
+ end
313
+
314
+ it 'Custom logger' do
315
+ log_output = StringIO.new
316
+ logger = Logger.new log_output
317
+
318
+ tool = GoalsByPlayer.new
319
+
320
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
321
+ content: question,
322
+ tools: [tool],
323
+ response: tool_response
324
+
325
+ expected_tool_result_content = [{
326
+ type: 'tool_result',
327
+ tool_use_id: 'toolu_01A09q90qw90lq917835lq9',
328
+ content: tool_result
329
+ }]
330
+
331
+ stub_client_request role: Rasti::AI::Anthropic::Roles::USER,
332
+ content: expected_tool_result_content,
333
+ tools: [tool],
334
+ response: basic_response(answer)
335
+
336
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client, tools: [tool], logger: logger
337
+
338
+ response = assistant.call question
339
+
340
+ assert_equal answer, response
341
+
342
+ refute_empty log_output.string
343
+
344
+ client.verify
345
+ end
346
+
347
+ end
348
+
349
+ end
@@ -0,0 +1,203 @@
1
+ require 'minitest_helper'
2
+
3
+ describe Rasti::AI::Anthropic::Client do
4
+
5
+ let(:api_url) { 'https://api.anthropic.com/v1/messages' }
6
+
7
+ def user_message(content)
8
+ {
9
+ role: Rasti::AI::Anthropic::Roles::USER,
10
+ content: content
11
+ }
12
+ end
13
+
14
+ describe 'Basic message' do
15
+
16
+ let(:question) { 'who is Messi?' }
17
+
18
+ let(:answer) { 'Lionel Messi is the best player ever' }
19
+
20
+ def stub_anthropic_messages(api_key:nil, model:nil)
21
+ api_key ||= Rasti::AI.anthropic_api_key
22
+ model ||= Rasti::AI.anthropic_default_model
23
+
24
+ stub_request(:post, api_url)
25
+ .with(
26
+ headers: {
27
+ 'x-api-key' => api_key,
28
+ 'anthropic-version' => '2023-06-01'
29
+ },
30
+ body: read_resource('anthropic/basic_request.json', model: model, prompt: question)
31
+ )
32
+ .to_return(body: read_resource('anthropic/basic_response.json', content: answer))
33
+ end
34
+
35
+ def assert_response_content(response, expected_content)
36
+ text_block = response['content'].find { |b| b['type'] == 'text' }
37
+ assert_equal expected_content, text_block['text']
38
+ end
39
+
40
+ it 'Default API key, model and logger' do
41
+ stub_anthropic_messages
42
+
43
+ client = Rasti::AI::Anthropic::Client.new
44
+
45
+ response = client.messages messages: [user_message(question)]
46
+
47
+ assert_response_content response, answer
48
+ end
49
+
50
+ it 'Custom API key' do
51
+ custom_api_key = SecureRandom.uuid
52
+
53
+ stub_anthropic_messages api_key: custom_api_key
54
+
55
+ client = Rasti::AI::Anthropic::Client.new api_key: custom_api_key
56
+
57
+ response = client.messages messages: [user_message(question)]
58
+
59
+ assert_response_content response, answer
60
+ end
61
+
62
+ it 'Custom model' do
63
+ custom_model = SecureRandom.uuid
64
+
65
+ stub_anthropic_messages model: custom_model
66
+
67
+ client = Rasti::AI::Anthropic::Client.new
68
+
69
+ response = client.messages messages: [user_message(question)], model: custom_model
70
+
71
+ assert_response_content response, answer
72
+ end
73
+
74
+ it 'Custom logger' do
75
+ log_output = StringIO.new
76
+ logger = Logger.new log_output
77
+
78
+ stub_anthropic_messages
79
+
80
+ client = Rasti::AI::Anthropic::Client.new logger: logger
81
+
82
+ response = client.messages messages: [user_message(question)]
83
+
84
+ assert_response_content response, answer
85
+
86
+ refute_empty log_output.string
87
+ end
88
+
89
+ describe 'Usage tracker' do
90
+
91
+ it 'Track usage' do
92
+ stub_anthropic_messages
93
+
94
+ tracked = []
95
+ tracker = ->(usage) { tracked << usage }
96
+
97
+ client = Rasti::AI::Anthropic::Client.new usage_tracker: tracker
98
+
99
+ client.messages messages: [user_message(question)]
100
+
101
+ assert_equal 1, tracked.count
102
+
103
+ expected_raw = {
104
+ 'input_tokens' => 25,
105
+ 'output_tokens' => 11,
106
+ 'cache_creation_input_tokens' => 0,
107
+ 'cache_read_input_tokens' => 0
108
+ }
109
+
110
+ usage = tracked[0]
111
+ assert_instance_of Rasti::AI::Usage, usage
112
+ assert_equal 'anthropic', usage.provider
113
+ assert_equal 'claude-test', usage.model
114
+ assert_equal 25, usage.input_tokens
115
+ assert_equal 11, usage.output_tokens
116
+ assert_equal 0, usage.cached_tokens
117
+ assert_equal 0, usage.reasoning_tokens
118
+ assert_equal expected_raw, usage.raw
119
+ end
120
+
121
+ it 'Without tracker' do
122
+ stub_anthropic_messages
123
+
124
+ client = Rasti::AI::Anthropic::Client.new
125
+
126
+ response = client.messages messages: [user_message(question)]
127
+
128
+ assert_response_content response, answer
129
+ end
130
+
131
+ end
132
+
133
+ end
134
+
135
+ it 'Request error' do
136
+ stub_request(:post, api_url)
137
+ .to_return(status: 400, body: '{"type":"error","error":{"type":"invalid_request_error","message":"Test error"}}')
138
+
139
+ client = Rasti::AI::Anthropic::Client.new
140
+
141
+ error = assert_raises(Rasti::AI::Errors::RequestFail) do
142
+ client.messages messages: ['invalid message']
143
+ end
144
+
145
+ assert_includes error.message, 'Response: 400'
146
+ end
147
+
148
+ it 'Tool call' do
149
+ question = 'how many goals did messi for barca'
150
+ tool_name = 'player_goals'
151
+
152
+ tool = {
153
+ name: tool_name,
154
+ description: 'Gets the number of goals scored by a player for a specific team',
155
+ input_schema: {
156
+ type: 'object',
157
+ properties: {
158
+ name: {
159
+ type: 'string',
160
+ description: 'Full name of the player'
161
+ },
162
+ team: {
163
+ type: 'string',
164
+ description: 'Name of the team the player was part of'
165
+ }
166
+ },
167
+ required: ['name', 'team']
168
+ }
169
+ }
170
+
171
+ arguments = {
172
+ name: 'Lionel Messi',
173
+ team: 'FC Barcelona'
174
+ }
175
+
176
+ stub_request(:post, api_url)
177
+ .with(
178
+ headers: {
179
+ 'x-api-key' => Rasti::AI.anthropic_api_key,
180
+ 'anthropic-version' => '2023-06-01'
181
+ },
182
+ body: read_resource(
183
+ 'anthropic/tool_request.json',
184
+ model: Rasti::AI.anthropic_default_model,
185
+ prompt: question,
186
+ tools: [tool]
187
+ )
188
+ )
189
+ .to_return(body: read_resource('anthropic/tool_response.json', name: tool_name, arguments: arguments))
190
+
191
+ client = Rasti::AI::Anthropic::Client.new
192
+
193
+ response = client.messages messages: [{role: 'user', content: question}],
194
+ tools: [tool],
195
+ tool_choice: {type: 'auto'}
196
+
197
+ tool_use = response['content'].find { |b| b['type'] == 'tool_use' }
198
+
199
+ assert_equal tool_name, tool_use['name']
200
+ assert_equal({'name' => 'Lionel Messi', 'team' => 'FC Barcelona'}, tool_use['input'])
201
+ end
202
+
203
+ end
@@ -110,6 +110,21 @@ describe Rasti::AI::Gemini::Assistant do
110
110
  assert_equal answer, response
111
111
  end
112
112
 
113
+ it 'Thinking' do
114
+ body = read_json_resource('gemini/basic_request.json', prompt: question)
115
+ body['generation_config'] = {'thinking_config' => {'thinking_budget' => 8_192}}
116
+
117
+ stub_request(:post, api_url)
118
+ .with(body: JSON.dump(body))
119
+ .to_return(body: read_resource('gemini/basic_response.json', content: answer))
120
+
121
+ assistant = Rasti::AI::Gemini::Assistant.new thinking: 'medium'
122
+
123
+ response = assistant.call question
124
+
125
+ assert_equal answer, response
126
+ end
127
+
113
128
  it 'JSON Schema' do
114
129
  json_schema = {answer: 'Response answer'}
115
130
  json_answer = "{\\\"answer\\\": \\\"#{answer}\\\"}"
@@ -11,12 +11,14 @@ describe Rasti::AI::MCP::Client do
11
11
  def stub_mcp_request(method, params:{}, result:{}, error:nil)
12
12
  request_body = {
13
13
  jsonrpc: '2.0',
14
+ id: 1,
14
15
  method: method,
15
16
  params: params
16
17
  }
17
18
 
18
19
  response_body = {
19
- jsonrpc: '2.0'
20
+ jsonrpc: '2.0',
21
+ id: 1
20
22
  }
21
23
 
22
24
  if error