rasti-ai 3.0.1 → 3.2.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/.features/api-normalization/api-design.md +184 -0
- data/.features/api-normalization/overview.md +230 -0
- data/AGENTS.md +167 -10
- data/README.md +161 -23
- data/lib/rasti/ai/anthropic/assistant.rb +1 -11
- data/lib/rasti/ai/anthropic/client.rb +6 -28
- data/lib/rasti/ai/anthropic/provider.rb +87 -0
- data/lib/rasti/ai/assistant.rb +10 -11
- data/lib/rasti/ai/client.rb +13 -10
- data/lib/rasti/ai/errors.rb +8 -0
- data/lib/rasti/ai/gemini/assistant.rb +1 -11
- data/lib/rasti/ai/gemini/client.rb +2 -24
- data/lib/rasti/ai/gemini/provider.rb +90 -0
- data/lib/rasti/ai/huawei_maas/assistant.rb +8 -0
- data/lib/rasti/ai/huawei_maas/client.rb +8 -0
- data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
- data/lib/rasti/ai/huawei_maas/roles.rb +7 -0
- data/lib/rasti/ai/open_ai/assistant.rb +0 -4
- data/lib/rasti/ai/open_ai/client.rb +4 -26
- data/lib/rasti/ai/open_ai/provider.rb +74 -0
- data/lib/rasti/ai/open_router/assistant.rb +8 -0
- data/lib/rasti/ai/open_router/client.rb +8 -0
- data/lib/rasti/ai/open_router/provider.rb +25 -0
- data/lib/rasti/ai/open_router/roles.rb +7 -0
- data/lib/rasti/ai/provider.rb +197 -0
- data/lib/rasti/ai/provider_aware.rb +17 -0
- data/lib/rasti/ai/result.rb +11 -0
- data/lib/rasti/ai/roles.rb +11 -0
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +101 -1
- data/spec/anthropic/provider_spec.rb +106 -0
- data/spec/gemini/provider_spec.rb +90 -0
- data/spec/huawei_maas/assistant_spec.rb +80 -0
- data/spec/huawei_maas/client_spec.rb +60 -0
- data/spec/minitest_helper.rb +6 -0
- data/spec/open_ai/provider_spec.rb +101 -0
- data/spec/open_router/assistant_spec.rb +80 -0
- data/spec/open_router/client_spec.rb +60 -0
- data/spec/rasti_ai_spec.rb +322 -0
- data/spec/resources/open_ai/conversation_request.json +1 -0
- data/spec/resources/open_ai/system_request.json +1 -0
- data/tasks/assistant.rake +5 -3
- metadata +39 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::HuaweiMaaS::Client do
|
|
4
|
+
|
|
5
|
+
let(:api_url) { 'https://api-ap-southeast-1.modelarts-maas.com/v2/chat/completions' }
|
|
6
|
+
|
|
7
|
+
let(:question) { 'who is Messi?' }
|
|
8
|
+
|
|
9
|
+
let(:answer) { 'Lionel Messi is the best player ever' }
|
|
10
|
+
|
|
11
|
+
def user_message(content)
|
|
12
|
+
{role: Rasti::AI::HuaweiMaaS::Roles::USER, content: content}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'Basic message with correct base URL and auth header' do
|
|
16
|
+
stub_request(:post, api_url)
|
|
17
|
+
.with(
|
|
18
|
+
headers: {'Authorization' => "Bearer #{Rasti::AI.huawei_maas_api_key}"},
|
|
19
|
+
body: read_resource('open_ai/basic_request.json', model: Rasti::AI.huawei_maas_default_model, prompt: question)
|
|
20
|
+
)
|
|
21
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
22
|
+
|
|
23
|
+
client = Rasti::AI::HuaweiMaaS::Client.new
|
|
24
|
+
|
|
25
|
+
response = client.chat_completions messages: [user_message(question)]
|
|
26
|
+
|
|
27
|
+
assert_equal answer, response.dig('choices', 0, 'message', 'content')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'Usage tracking with provider name' do
|
|
31
|
+
stub_request(:post, api_url)
|
|
32
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
33
|
+
|
|
34
|
+
tracked = []
|
|
35
|
+
tracker = ->(usage) { tracked << usage }
|
|
36
|
+
|
|
37
|
+
client = Rasti::AI::HuaweiMaaS::Client.new usage_tracker: tracker
|
|
38
|
+
|
|
39
|
+
client.chat_completions messages: [user_message(question)]
|
|
40
|
+
|
|
41
|
+
assert_equal 1, tracked.count
|
|
42
|
+
assert_instance_of Rasti::AI::Usage, tracked[0]
|
|
43
|
+
assert_equal 'huawei_maas', tracked[0].provider
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'Custom model' do
|
|
47
|
+
model = SecureRandom.uuid
|
|
48
|
+
|
|
49
|
+
stub_request(:post, api_url)
|
|
50
|
+
.with(body: read_resource('open_ai/basic_request.json', model: model, prompt: question))
|
|
51
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
52
|
+
|
|
53
|
+
client = Rasti::AI::HuaweiMaaS::Client.new
|
|
54
|
+
|
|
55
|
+
response = client.chat_completions messages: [user_message(question)], model: model
|
|
56
|
+
|
|
57
|
+
assert_equal answer, response.dig('choices', 0, 'message', 'content')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
end
|
data/spec/minitest_helper.rb
CHANGED
|
@@ -25,6 +25,12 @@ Rasti::AI.configure do |config|
|
|
|
25
25
|
|
|
26
26
|
config.anthropic_api_key = 'test_anthropic_api_key'
|
|
27
27
|
config.anthropic_default_model = 'claude-test'
|
|
28
|
+
|
|
29
|
+
config.openrouter_api_key = 'test_openrouter_api_key'
|
|
30
|
+
config.openrouter_default_model = 'openrouter-test'
|
|
31
|
+
|
|
32
|
+
config.huawei_maas_api_key = 'test_huawei_maas_api_key'
|
|
33
|
+
config.huawei_maas_default_model = 'huawei-test'
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::OpenAI::Provider do
|
|
4
|
+
|
|
5
|
+
let(:question) { 'How many goals has Messi scored for Barca?' }
|
|
6
|
+
|
|
7
|
+
let(:answer) { 'Lionel Messi scored 672 goals in 778 official matches for FC Barcelona.' }
|
|
8
|
+
|
|
9
|
+
def stub_chat_completions(url, request_body)
|
|
10
|
+
stub_request(:post, url)
|
|
11
|
+
.with(body: JSON.dump(request_body))
|
|
12
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def basic_body(model)
|
|
16
|
+
{
|
|
17
|
+
model: model,
|
|
18
|
+
messages: [
|
|
19
|
+
{
|
|
20
|
+
role: 'user',
|
|
21
|
+
content: question
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
tools: [],
|
|
25
|
+
tool_choice: 'none'
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'Name, model and classes' do
|
|
30
|
+
provider = Rasti::AI.provider :open_ai
|
|
31
|
+
|
|
32
|
+
assert_equal :open_ai, provider.name
|
|
33
|
+
assert_equal Rasti::AI.openai_default_model, provider.model
|
|
34
|
+
assert_instance_of Rasti::AI::OpenAI::Client, provider.build_client
|
|
35
|
+
assert_instance_of Rasti::AI::OpenAI::Assistant, provider.create_assistant
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'Generate text' do
|
|
39
|
+
stub_chat_completions 'https://api.openai.com/v1/chat/completions', basic_body(Rasti::AI.openai_default_model)
|
|
40
|
+
|
|
41
|
+
result = Rasti::AI.provider(:open_ai).generate_text prompt: question
|
|
42
|
+
|
|
43
|
+
assert_equal answer, result.content
|
|
44
|
+
assert_equal 'open_ai', result.usage.provider
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'Generate text with json schema and thinking' do
|
|
48
|
+
json_schema = {player: 'string'}
|
|
49
|
+
|
|
50
|
+
body = basic_body(Rasti::AI.openai_default_model).merge(
|
|
51
|
+
response_format: {
|
|
52
|
+
type: 'json_schema',
|
|
53
|
+
json_schema: json_schema
|
|
54
|
+
},
|
|
55
|
+
reasoning_effort: 'low'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
stub_chat_completions 'https://api.openai.com/v1/chat/completions', body
|
|
59
|
+
|
|
60
|
+
result = Rasti::AI.provider(:open_ai).generate_text prompt: question, json_schema: json_schema, thinking: 'low'
|
|
61
|
+
|
|
62
|
+
assert_equal answer, result.content
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe 'OpenAI compatible providers' do
|
|
66
|
+
|
|
67
|
+
it 'OpenRouter' do
|
|
68
|
+
provider = Rasti::AI.provider :open_router
|
|
69
|
+
|
|
70
|
+
assert_equal :open_router, provider.name
|
|
71
|
+
assert_equal Rasti::AI.openrouter_default_model, provider.model
|
|
72
|
+
assert_instance_of Rasti::AI::OpenRouter::Client, provider.build_client
|
|
73
|
+
assert_instance_of Rasti::AI::OpenRouter::Assistant, provider.create_assistant
|
|
74
|
+
|
|
75
|
+
stub_chat_completions 'https://openrouter.ai/api/v1/chat/completions', basic_body(Rasti::AI.openrouter_default_model)
|
|
76
|
+
|
|
77
|
+
result = provider.generate_text prompt: question
|
|
78
|
+
|
|
79
|
+
assert_equal answer, result.content
|
|
80
|
+
assert_equal 'open_router', result.usage.provider
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'Huawei MaaS' do
|
|
84
|
+
provider = Rasti::AI.provider :huawei_maas
|
|
85
|
+
|
|
86
|
+
assert_equal :huawei_maas, provider.name
|
|
87
|
+
assert_equal Rasti::AI.huawei_maas_default_model, provider.model
|
|
88
|
+
assert_instance_of Rasti::AI::HuaweiMaaS::Client, provider.build_client
|
|
89
|
+
assert_instance_of Rasti::AI::HuaweiMaaS::Assistant, provider.create_assistant
|
|
90
|
+
|
|
91
|
+
stub_chat_completions 'https://api-ap-southeast-1.modelarts-maas.com/v2/chat/completions', basic_body(Rasti::AI.huawei_maas_default_model)
|
|
92
|
+
|
|
93
|
+
result = provider.generate_text prompt: question
|
|
94
|
+
|
|
95
|
+
assert_equal answer, result.content
|
|
96
|
+
assert_equal 'huawei_maas', result.usage.provider
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::OpenRouter::Assistant do
|
|
4
|
+
|
|
5
|
+
let(:api_url) { 'https://openrouter.ai/api/v1/chat/completions' }
|
|
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
|
+
it 'Default' do
|
|
12
|
+
stub_request(:post, api_url)
|
|
13
|
+
.with(body: read_resource('open_ai/basic_request.json', model: Rasti::AI.openrouter_default_model, prompt: question))
|
|
14
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
15
|
+
|
|
16
|
+
assistant = Rasti::AI::OpenRouter::Assistant.new
|
|
17
|
+
|
|
18
|
+
response = assistant.call question
|
|
19
|
+
|
|
20
|
+
assert_equal answer, response
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'Tools' do
|
|
24
|
+
|
|
25
|
+
let(:client) { Minitest::Mock.new }
|
|
26
|
+
|
|
27
|
+
let(:tool_response) do
|
|
28
|
+
read_json_resource(
|
|
29
|
+
'open_ai/tool_response.json',
|
|
30
|
+
name: 'goals_by_player',
|
|
31
|
+
arguments: {player: 'Lionel Messi', team: 'Barcelona'}
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
let(:tool_result) { '672' }
|
|
36
|
+
|
|
37
|
+
let(:answer_with_tool) { 'Lionel Messi scored 672 goals for FC Barcelona.' }
|
|
38
|
+
|
|
39
|
+
def basic_response(content)
|
|
40
|
+
read_json_resource('open_ai/basic_response.json', content: content)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stub_client_request(role:, content:, response:, tools:[])
|
|
44
|
+
serialized_tools = tools.map do |tool|
|
|
45
|
+
{type: 'function', function: Rasti::AI::ToolSerializer.serialize(tool.class)}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
client.expect :chat_completions, response do |params|
|
|
49
|
+
last_message = params[:messages].last
|
|
50
|
+
last_message[:role] == role &&
|
|
51
|
+
last_message[:content] == content &&
|
|
52
|
+
params[:tools] == serialized_tools
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'Call tool' do
|
|
57
|
+
tool = GoalsByPlayer.new
|
|
58
|
+
|
|
59
|
+
stub_client_request role: Rasti::AI::OpenRouter::Roles::USER,
|
|
60
|
+
content: question,
|
|
61
|
+
tools: [tool],
|
|
62
|
+
response: tool_response
|
|
63
|
+
|
|
64
|
+
stub_client_request role: Rasti::AI::OpenRouter::Roles::TOOL,
|
|
65
|
+
content: tool_result,
|
|
66
|
+
tools: [tool],
|
|
67
|
+
response: basic_response(answer_with_tool)
|
|
68
|
+
|
|
69
|
+
assistant = Rasti::AI::OpenRouter::Assistant.new client: client, tools: [tool]
|
|
70
|
+
|
|
71
|
+
response = assistant.call question
|
|
72
|
+
|
|
73
|
+
assert_equal answer_with_tool, response
|
|
74
|
+
|
|
75
|
+
client.verify
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::OpenRouter::Client do
|
|
4
|
+
|
|
5
|
+
let(:api_url) { 'https://openrouter.ai/api/v1/chat/completions' }
|
|
6
|
+
|
|
7
|
+
let(:question) { 'who is Messi?' }
|
|
8
|
+
|
|
9
|
+
let(:answer) { 'Lionel Messi is the best player ever' }
|
|
10
|
+
|
|
11
|
+
def user_message(content)
|
|
12
|
+
{role: Rasti::AI::OpenRouter::Roles::USER, content: content}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'Basic message with correct base URL and auth header' do
|
|
16
|
+
stub_request(:post, api_url)
|
|
17
|
+
.with(
|
|
18
|
+
headers: {'Authorization' => "Bearer #{Rasti::AI.openrouter_api_key}"},
|
|
19
|
+
body: read_resource('open_ai/basic_request.json', model: Rasti::AI.openrouter_default_model, prompt: question)
|
|
20
|
+
)
|
|
21
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
22
|
+
|
|
23
|
+
client = Rasti::AI::OpenRouter::Client.new
|
|
24
|
+
|
|
25
|
+
response = client.chat_completions messages: [user_message(question)]
|
|
26
|
+
|
|
27
|
+
assert_equal answer, response.dig('choices', 0, 'message', 'content')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'Usage tracking with provider name' do
|
|
31
|
+
stub_request(:post, api_url)
|
|
32
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
33
|
+
|
|
34
|
+
tracked = []
|
|
35
|
+
tracker = ->(usage) { tracked << usage }
|
|
36
|
+
|
|
37
|
+
client = Rasti::AI::OpenRouter::Client.new usage_tracker: tracker
|
|
38
|
+
|
|
39
|
+
client.chat_completions messages: [user_message(question)]
|
|
40
|
+
|
|
41
|
+
assert_equal 1, tracked.count
|
|
42
|
+
assert_instance_of Rasti::AI::Usage, tracked[0]
|
|
43
|
+
assert_equal 'open_router', tracked[0].provider
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'Custom model' do
|
|
47
|
+
model = SecureRandom.uuid
|
|
48
|
+
|
|
49
|
+
stub_request(:post, api_url)
|
|
50
|
+
.with(body: read_resource('open_ai/basic_request.json', model: model, prompt: question))
|
|
51
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
52
|
+
|
|
53
|
+
client = Rasti::AI::OpenRouter::Client.new
|
|
54
|
+
|
|
55
|
+
response = client.chat_completions messages: [user_message(question)], model: model
|
|
56
|
+
|
|
57
|
+
assert_equal answer, response.dig('choices', 0, 'message', 'content')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI do
|
|
4
|
+
|
|
5
|
+
let(:question) { 'How many goals has Messi scored for Barca?' }
|
|
6
|
+
|
|
7
|
+
let(:answer) { 'Lionel Messi scored 672 goals in 778 official matches for FC Barcelona.' }
|
|
8
|
+
|
|
9
|
+
def stub_open_ai(model:nil, prompt:nil, content:nil)
|
|
10
|
+
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
|
11
|
+
.with(body: read_resource('open_ai/basic_request.json', model: model || Rasti::AI.openai_default_model, prompt: prompt || question))
|
|
12
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: content || answer))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def with_default_provider(provider)
|
|
16
|
+
previous = Rasti::AI.default_provider
|
|
17
|
+
Rasti::AI.default_provider = provider
|
|
18
|
+
yield
|
|
19
|
+
ensure
|
|
20
|
+
Rasti::AI.default_provider = previous
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'Create assistant' do
|
|
24
|
+
|
|
25
|
+
it 'Provider' do
|
|
26
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai
|
|
27
|
+
|
|
28
|
+
assert_instance_of Rasti::AI::OpenAI::Assistant, assistant
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'Provider alias' do
|
|
32
|
+
assistant = Rasti::AI.create_assistant provider: 'openai'
|
|
33
|
+
|
|
34
|
+
assert_instance_of Rasti::AI::OpenAI::Assistant, assistant
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'Provider and model' do
|
|
38
|
+
assistant = Rasti::AI.create_assistant provider: :anthropic, model: 'claude-sonnet-4-5'
|
|
39
|
+
|
|
40
|
+
assert_instance_of Rasti::AI::Anthropic::Assistant, assistant
|
|
41
|
+
assert_equal 'claude-sonnet-4-5', assistant.model
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'Provider and model as single string' do
|
|
45
|
+
assistant = Rasti::AI.create_assistant model: 'gemini:gemini-2.0-flash'
|
|
46
|
+
|
|
47
|
+
assert_instance_of Rasti::AI::Gemini::Assistant, assistant
|
|
48
|
+
assert_equal 'gemini-2.0-flash', assistant.model
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'Default provider from config' do
|
|
52
|
+
with_default_provider :anthropic do
|
|
53
|
+
assistant = Rasti::AI.create_assistant
|
|
54
|
+
|
|
55
|
+
assert_instance_of Rasti::AI::Anthropic::Assistant, assistant
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'Explicit provider takes precedence over config' do
|
|
60
|
+
with_default_provider :anthropic do
|
|
61
|
+
assistant = Rasti::AI.create_assistant provider: :gemini
|
|
62
|
+
|
|
63
|
+
assert_instance_of Rasti::AI::Gemini::Assistant, assistant
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'Undefined provider' do
|
|
68
|
+
with_default_provider nil do
|
|
69
|
+
error = assert_raises ArgumentError do
|
|
70
|
+
Rasti::AI.create_assistant
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
assert_match /\AUndefined provider/, error.message
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'Unknown provider' do
|
|
78
|
+
assert_raises Rasti::AI::Errors::UnknownProvider do
|
|
79
|
+
Rasti::AI.create_assistant provider: :invalid
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'Thinking, tools and json schema' do
|
|
84
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai,
|
|
85
|
+
thinking: 'high',
|
|
86
|
+
tools: [GoalsByPlayer.new],
|
|
87
|
+
json_schema: {player: 'string'}
|
|
88
|
+
|
|
89
|
+
assert_equal 'high', assistant.thinking
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'System' do
|
|
93
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai, system: 'Act as sports journalist'
|
|
94
|
+
|
|
95
|
+
assert_equal 'Act as sports journalist', assistant.state.context
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'State' do
|
|
99
|
+
state = Rasti::AI::AssistantState.new context: 'Act as sports journalist'
|
|
100
|
+
|
|
101
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai, state: state
|
|
102
|
+
|
|
103
|
+
assert_same state, assistant.state
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'State and system are mutually exclusive' do
|
|
107
|
+
error = assert_raises ArgumentError do
|
|
108
|
+
Rasti::AI.create_assistant provider: :open_ai, state: Rasti::AI::AssistantState.new, system: 'Act as sports journalist'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
assert_equal 'Use state or system, not both', error.message
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'Custom client' do
|
|
115
|
+
client = Rasti::AI::OpenAI::Client.new
|
|
116
|
+
|
|
117
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai, client: client
|
|
118
|
+
|
|
119
|
+
stub_open_ai
|
|
120
|
+
|
|
121
|
+
assert_equal answer, assistant.call(question)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'Client options' do
|
|
125
|
+
stub_open_ai
|
|
126
|
+
|
|
127
|
+
tracked_usage = []
|
|
128
|
+
|
|
129
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai,
|
|
130
|
+
api_key: 'custom_api_key',
|
|
131
|
+
usage_tracker: ->(usage) { tracked_usage << usage },
|
|
132
|
+
http_read_timeout: 120,
|
|
133
|
+
http_max_retries: 5
|
|
134
|
+
|
|
135
|
+
assistant.call question
|
|
136
|
+
|
|
137
|
+
assert_requested :post, 'https://api.openai.com/v1/chat/completions',
|
|
138
|
+
headers: {'Authorization' => 'Bearer custom_api_key'}
|
|
139
|
+
|
|
140
|
+
assert_equal 1, tracked_usage.count
|
|
141
|
+
assert_equal 'open_ai', tracked_usage.first.provider
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe 'Provider' do
|
|
147
|
+
|
|
148
|
+
it 'Build from name' do
|
|
149
|
+
provider = Rasti::AI.provider :open_ai
|
|
150
|
+
|
|
151
|
+
assert_instance_of Rasti::AI::OpenAI::Provider, provider
|
|
152
|
+
assert_equal :open_ai, provider.name
|
|
153
|
+
assert_equal Rasti::AI.openai_default_model, provider.model
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'Build from alias' do
|
|
157
|
+
assert_instance_of Rasti::AI::OpenRouter::Provider, Rasti::AI.provider('openrouter')
|
|
158
|
+
assert_instance_of Rasti::AI::HuaweiMaaS::Provider, Rasti::AI.provider(:huawei)
|
|
159
|
+
assert_instance_of Rasti::AI::OpenAI::Provider, Rasti::AI.provider(:OpenAI)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'Build from provider and model as single string' do
|
|
163
|
+
provider = Rasti::AI.provider model: 'anthropic:claude-sonnet-4-5'
|
|
164
|
+
|
|
165
|
+
assert_instance_of Rasti::AI::Anthropic::Provider, provider
|
|
166
|
+
assert_equal 'claude-sonnet-4-5', provider.model
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'Names' do
|
|
170
|
+
assert_equal [:open_ai, :gemini, :anthropic, :open_router, :huawei_maas], Rasti::AI::Provider.names
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'Unknown provider' do
|
|
174
|
+
error = assert_raises Rasti::AI::Errors::UnknownProvider do
|
|
175
|
+
Rasti::AI.provider :invalid
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
assert_equal 'Unknown provider :invalid. Valid providers: open_ai, gemini, anthropic, open_router, huawei_maas', error.message
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'Builds client and assistant of its own provider' do
|
|
182
|
+
provider = Rasti::AI.provider :gemini
|
|
183
|
+
|
|
184
|
+
assert_instance_of Rasti::AI::Gemini::Client, provider.build_client
|
|
185
|
+
assert_instance_of Rasti::AI::Gemini::Assistant, provider.create_assistant
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'Reusable instance' do
|
|
189
|
+
provider = Rasti::AI.provider :open_ai, api_key: 'tenant_api_key'
|
|
190
|
+
|
|
191
|
+
stub_open_ai
|
|
192
|
+
|
|
193
|
+
provider.generate_text prompt: question
|
|
194
|
+
|
|
195
|
+
assert_requested :post, 'https://api.openai.com/v1/chat/completions',
|
|
196
|
+
headers: {'Authorization' => 'Bearer tenant_api_key'}
|
|
197
|
+
|
|
198
|
+
assert_instance_of Rasti::AI::OpenAI::Assistant, provider.create_assistant
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'Invalid thinking level' do
|
|
202
|
+
error = assert_raises ArgumentError do
|
|
203
|
+
Rasti::AI.provider(:open_ai).generate_text prompt: question, thinking: 'invalid'
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
assert_equal "Invalid thinking level 'invalid'. Valid: low, medium, high", error.message
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe 'Generate text' do
|
|
212
|
+
|
|
213
|
+
it 'Provider' do
|
|
214
|
+
stub_open_ai
|
|
215
|
+
|
|
216
|
+
result = Rasti::AI.generate_text prompt: question, provider: :open_ai
|
|
217
|
+
|
|
218
|
+
assert_instance_of Rasti::AI::Result, result
|
|
219
|
+
assert_equal answer, result.content
|
|
220
|
+
assert_equal 'open_ai', result.usage.provider
|
|
221
|
+
assert_equal 27, result.usage.input_tokens
|
|
222
|
+
assert_equal 229, result.usage.output_tokens
|
|
223
|
+
assert_equal 'stop', result.raw['choices'][0]['finish_reason']
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'Provider and model' do
|
|
227
|
+
stub_open_ai model: 'gpt-4o'
|
|
228
|
+
|
|
229
|
+
result = Rasti::AI.generate_text prompt: question, provider: :open_ai, model: 'gpt-4o'
|
|
230
|
+
|
|
231
|
+
assert_equal answer, result.content
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'Provider and model as single string' do
|
|
235
|
+
stub_open_ai model: 'gpt-4o'
|
|
236
|
+
|
|
237
|
+
result = Rasti::AI.generate_text prompt: question, model: 'open_ai:gpt-4o'
|
|
238
|
+
|
|
239
|
+
assert_equal answer, result.content
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it 'Default provider from config' do
|
|
243
|
+
with_default_provider :open_ai do
|
|
244
|
+
stub_open_ai
|
|
245
|
+
|
|
246
|
+
assert_equal answer, Rasti::AI.generate_text(prompt: question).content
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'Messages with generic roles' do
|
|
251
|
+
messages = [
|
|
252
|
+
{role: Rasti::AI::Roles::USER, content: 'who is the best player'},
|
|
253
|
+
{role: Rasti::AI::Roles::ASSISTANT, content: 'Lionel Messi'},
|
|
254
|
+
{role: Rasti::AI::Roles::USER, content: question}
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
|
258
|
+
.with(body: read_resource('open_ai/conversation_request.json', model: Rasti::AI.openai_default_model, prompt: question))
|
|
259
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
260
|
+
|
|
261
|
+
result = Rasti::AI.generate_text messages: messages, provider: :open_ai
|
|
262
|
+
|
|
263
|
+
assert_equal answer, result.content
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it 'System message inside messages' do
|
|
267
|
+
messages = [
|
|
268
|
+
{role: Rasti::AI::Roles::SYSTEM, content: 'Act as sports journalist'},
|
|
269
|
+
{role: Rasti::AI::Roles::USER, content: question}
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
|
273
|
+
.with(body: read_resource('open_ai/system_request.json', model: Rasti::AI.openai_default_model, system: 'Act as sports journalist', prompt: question))
|
|
274
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
275
|
+
|
|
276
|
+
assert_equal answer, Rasti::AI.generate_text(messages: messages, provider: :open_ai).content
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'Prompt and messages are mutually exclusive' do
|
|
280
|
+
error = assert_raises ArgumentError do
|
|
281
|
+
Rasti::AI.generate_text prompt: question, messages: [], provider: :open_ai
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
assert_equal 'Use prompt or messages, not both', error.message
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'Undefined prompt and messages' do
|
|
288
|
+
error = assert_raises ArgumentError do
|
|
289
|
+
Rasti::AI.generate_text provider: :open_ai
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
assert_equal 'Undefined prompt or messages', error.message
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it 'System' do
|
|
296
|
+
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
|
297
|
+
.with(body: read_resource('open_ai/system_request.json', model: Rasti::AI.openai_default_model, system: 'Act as sports journalist', prompt: question))
|
|
298
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
299
|
+
|
|
300
|
+
result = Rasti::AI.generate_text prompt: question,
|
|
301
|
+
provider: :open_ai,
|
|
302
|
+
system: 'Act as sports journalist'
|
|
303
|
+
|
|
304
|
+
assert_equal answer, result.content
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'Usage tracker' do
|
|
308
|
+
stub_open_ai
|
|
309
|
+
|
|
310
|
+
tracked_usage = []
|
|
311
|
+
|
|
312
|
+
result = Rasti::AI.generate_text prompt: question,
|
|
313
|
+
provider: :open_ai,
|
|
314
|
+
usage_tracker: ->(usage) { tracked_usage << usage }
|
|
315
|
+
|
|
316
|
+
assert_equal 1, tracked_usage.count
|
|
317
|
+
assert_equal tracked_usage.first.to_h, result.usage.to_h
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"model":"<%= model %>","messages":[{"role":"user","content":"who is the best player"},{"role":"assistant","content":"Lionel Messi"},{"role":"user","content":"<%= prompt %>"}],"tools":[],"tool_choice":"none"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"model":"<%= model %>","messages":[{"role":"system","content":"<%= system %>"},{"role":"user","content":"<%= prompt %>"}],"tools":[],"tool_choice":"none"}
|
data/tasks/assistant.rake
CHANGED
|
@@ -19,9 +19,11 @@ require 'logger'
|
|
|
19
19
|
WEATHER_MCP_URL = 'https://gateway.pipeworx.io/weather/mcp'.freeze
|
|
20
20
|
|
|
21
21
|
PROVIDERS = {
|
|
22
|
-
'openai'
|
|
23
|
-
'gemini'
|
|
24
|
-
'anthropic'
|
|
22
|
+
'openai' => {key: 'OPENAI_API_KEY', klass: -> { Rasti::AI::OpenAI::Assistant }},
|
|
23
|
+
'gemini' => {key: 'GEMINI_API_KEY', klass: -> { Rasti::AI::Gemini::Assistant }},
|
|
24
|
+
'anthropic' => {key: 'ANTHROPIC_API_KEY', klass: -> { Rasti::AI::Anthropic::Assistant }},
|
|
25
|
+
'openrouter' => {key: 'OPENROUTER_API_KEY', klass: -> { Rasti::AI::OpenRouter::Assistant }},
|
|
26
|
+
'huawei_maas'=> {key: 'HUAWEI_MAAS_API_KEY', klass: -> { Rasti::AI::HuaweiMaaS::Assistant }}
|
|
25
27
|
}.freeze
|
|
26
28
|
|
|
27
29
|
def build_weather_mcp
|