rasti-ai 3.0.0 → 3.1.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/AGENTS.md +47 -0
- data/README.md +18 -4
- data/lib/rasti/ai/huawei_maas/assistant.rb +15 -0
- data/lib/rasti/ai/huawei_maas/client.rb +27 -0
- data/lib/rasti/ai/huawei_maas/roles.rb +14 -0
- data/lib/rasti/ai/open_ai/client.rb +10 -2
- data/lib/rasti/ai/open_router/assistant.rb +15 -0
- data/lib/rasti/ai/open_router/client.rb +27 -0
- data/lib/rasti/ai/open_router/roles.rb +14 -0
- data/lib/rasti/ai/tool_serializer.rb +16 -1
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +8 -1
- 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_router/assistant_spec.rb +80 -0
- data/spec/open_router/client_spec.rb +60 -0
- data/spec/tool_serializer_spec.rb +32 -2
- data/tasks/assistant.rake +5 -3
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81ed3eb7239ec9d47de179db57b6d55abb21c512ee6c830de189c77692bb1bf0
|
|
4
|
+
data.tar.gz: fb25a1ab3f55861676625021c91bfed4aea5c40058c3b6282041dadd39c9fdb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3a1f07c3580de138323486de7255633eb67e1e14a3ea7ec847fededaf6f2ba771cf425d5ac27f80ed7180582e8b2070562ff42c84a1dbedbc541da9e1bb6a59
|
|
7
|
+
data.tar.gz: afa8bf99945c06a833b62ae3d53e42860b49a923715473b8a1cb8b4793dc653b9d48b93a991676d7cc79c79aa714bc2a8d6c94156c9fdb36f60bea125f660936
|
data/AGENTS.md
CHANGED
|
@@ -76,6 +76,14 @@ lib/
|
|
|
76
76
|
roles.rb
|
|
77
77
|
client.rb
|
|
78
78
|
assistant.rb
|
|
79
|
+
open_router/
|
|
80
|
+
roles.rb
|
|
81
|
+
client.rb
|
|
82
|
+
assistant.rb
|
|
83
|
+
huawei_maas/
|
|
84
|
+
roles.rb
|
|
85
|
+
client.rb
|
|
86
|
+
assistant.rb
|
|
79
87
|
mcp/
|
|
80
88
|
server.rb # Rack middleware exposing tools via JSON-RPC 2.0
|
|
81
89
|
tools_registry.rb # per-request tool registry used by the middleware
|
|
@@ -100,6 +108,12 @@ spec/
|
|
|
100
108
|
anthropic/
|
|
101
109
|
client_spec.rb
|
|
102
110
|
assistant_spec.rb
|
|
111
|
+
open_router/
|
|
112
|
+
client_spec.rb
|
|
113
|
+
assistant_spec.rb
|
|
114
|
+
huawei_maas/
|
|
115
|
+
client_spec.rb
|
|
116
|
+
assistant_spec.rb
|
|
103
117
|
mcp/
|
|
104
118
|
client_spec.rb
|
|
105
119
|
server_spec.rb
|
|
@@ -140,6 +154,19 @@ spec/
|
|
|
140
154
|
- Gemini: renames to `parameters`
|
|
141
155
|
- Anthropic: renames to `input_schema`
|
|
142
156
|
|
|
157
|
+
### OpenAI-compatible providers
|
|
158
|
+
|
|
159
|
+
OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth. Their clients and assistants inherit from the OpenAI provider and override only `base_url`, `default_api_key`, `default_model`, and `provider_name` (client) / `build_default_client` (assistant). No template methods are reimplemented.
|
|
160
|
+
|
|
161
|
+
| | OpenRouter | Huawei MaaS |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| Base URL | `https://openrouter.ai/api/v1` | `https://api-ap-southeast-1.modelarts-maas.com/v2` |
|
|
164
|
+
| Auth | `Authorization: Bearer {key}` | `Authorization: Bearer {key}` |
|
|
165
|
+
| Config | `openrouter_api_key` / `openrouter_default_model` | `huawei_maas_api_key` / `huawei_maas_default_model` |
|
|
166
|
+
| `provider` in Usage | `'open_router'` | `'huawei_maas'` |
|
|
167
|
+
|
|
168
|
+
Thinking is inherited too: `reasoning_effort` is passed through as-is (same as OpenAI).
|
|
169
|
+
|
|
143
170
|
|
|
144
171
|
## Thinking levels
|
|
145
172
|
|
|
@@ -153,6 +180,8 @@ The base `Assistant` accepts `thinking: 'low' | 'medium' | 'high'` (validated on
|
|
|
153
180
|
|
|
154
181
|
For Gemini, `thinking_config` goes inside `generation_config` — the client doesn't need a new param. For OpenAI and Anthropic, it's a separate top-level param in the client method (`reasoning_effort:` and `thinking:` respectively).
|
|
155
182
|
|
|
183
|
+
OpenRouter and Huawei MaaS inherit OpenAI's behavior: `thinking` is passed through as `reasoning_effort` with the same string values (`'low'`/`'medium'`/`'high'`). No `thinking_config` override is needed. Note that Huawei's models typically accept only `'high'` and `'max'` — `'high'` is the safe choice, and `'max'` is not expressible through the gem's universal levels.
|
|
184
|
+
|
|
156
185
|
The loop does not change. Anthropic thinking blocks (`type: 'thinking'`) in responses are ignored by `parse_content` (looks for `type == 'text'`) and preserved automatically by `build_assistant_tool_calls_message` (passes full `response['content']` array).
|
|
157
186
|
|
|
158
187
|
|
|
@@ -189,6 +218,17 @@ config.<provider>_api_key = 'test_<provider>_api_key'
|
|
|
189
218
|
config.<provider>_default_model = '<provider>-test'
|
|
190
219
|
```
|
|
191
220
|
|
|
221
|
+
### OpenAI-compatible providers
|
|
222
|
+
|
|
223
|
+
If the new provider speaks the OpenAI chat completions protocol (same endpoint, request body, response shape, tool calling, Bearer auth), inherit from `Rasti::AI::OpenAI::Client` and `Rasti::AI::OpenAI::Assistant` instead of the abstract bases. The assistant only overrides `build_default_client`; the client overrides `base_url`, `default_api_key`, `default_model`, and `provider_name`. No template methods are reimplemented.
|
|
224
|
+
|
|
225
|
+
The OpenAI client exposes two private hooks that make this possible without duplicating `chat_completions` or `parse_usage`:
|
|
226
|
+
|
|
227
|
+
- **`default_model`** — returns the provider's default model from `Rasti::AI` config
|
|
228
|
+
- **`provider_name`** — the string written to `Usage#provider`
|
|
229
|
+
|
|
230
|
+
See `open_router/` and `huawei_maas/` for reference implementations.
|
|
231
|
+
|
|
192
232
|
### ⚠️ multi_require load order
|
|
193
233
|
|
|
194
234
|
`require_relative_pattern 'ai/**/*'` loads files alphabetically. If the new provider name sorts before `assistant` or `client` (e.g. `anthropic` < `assistant`), the subclass is loaded before the base class and raises `NameError`.
|
|
@@ -203,9 +243,14 @@ require_relative 'ai/tool'
|
|
|
203
243
|
require_relative 'ai/tool_serializer'
|
|
204
244
|
require_relative 'ai/client'
|
|
205
245
|
require_relative 'ai/assistant'
|
|
246
|
+
require_relative 'ai/open_ai/roles'
|
|
247
|
+
require_relative 'ai/open_ai/client'
|
|
248
|
+
require_relative 'ai/open_ai/assistant'
|
|
206
249
|
require_relative_pattern 'ai/**/*' # duplicates are skipped by Ruby's require
|
|
207
250
|
```
|
|
208
251
|
|
|
252
|
+
The OpenAI provider is also explicitly required because OpenAI-compatible providers (OpenRouter, Huawei MaaS) inherit from it, and some of their directory names sort before `open_ai` alphabetically (e.g. `huawei_maas` < `open_ai`).
|
|
253
|
+
|
|
209
254
|
If you add a provider whose name sorts before `client` alphabetically, the same mechanism protects it.
|
|
210
255
|
|
|
211
256
|
|
|
@@ -519,6 +564,8 @@ It's a simple class (not a `Tool` subclass) that returns a plain string — cove
|
|
|
519
564
|
rake assistant:openai # OPENAI_API_KEY
|
|
520
565
|
rake assistant:gemini # GEMINI_API_KEY
|
|
521
566
|
rake assistant:anthropic # ANTHROPIC_API_KEY
|
|
567
|
+
rake assistant:openrouter # OPENROUTER_API_KEY
|
|
568
|
+
rake assistant:huawei_maas # HUAWEI_MAAS_API_KEY
|
|
522
569
|
```
|
|
523
570
|
Each task validates the key, writes logs to `log/<provider>.log`, connects to the [Pipeworx](https://pipeworx.io) public weather MCP server, and starts a `You:` / `Assistant:` prompt loop (`exit` or `Ctrl+C` to quit). The model can be overridden with the matching env variable (e.g. `OPENAI_DEFAULT_MODEL=gpt-4o`).
|
|
524
571
|
|
data/README.md
CHANGED
|
@@ -45,6 +45,14 @@ Rasti::AI.configure do |config|
|
|
|
45
45
|
config.anthropic_api_key = 'sk-ant-12345' # Default ENV['ANTHROPIC_API_KEY']
|
|
46
46
|
config.anthropic_default_model = 'claude-opus-4-5' # Default ENV['ANTHROPIC_DEFAULT_MODEL']
|
|
47
47
|
|
|
48
|
+
# OpenRouter
|
|
49
|
+
config.openrouter_api_key = 'sk-or-12345' # Default ENV['OPENROUTER_API_KEY']
|
|
50
|
+
config.openrouter_default_model = 'openai/gpt-4o-mini' # Default ENV['OPENROUTER_DEFAULT_MODEL']
|
|
51
|
+
|
|
52
|
+
# Huawei MaaS
|
|
53
|
+
config.huawei_maas_api_key = 'sk-maas-12345' # Default ENV['HUAWEI_MAAS_API_KEY']
|
|
54
|
+
config.huawei_maas_default_model = 'DeepSeek-V3' # Default ENV['HUAWEI_MAAS_DEFAULT_MODEL']
|
|
55
|
+
|
|
48
56
|
# Usage tracking
|
|
49
57
|
config.usage_tracker = ->(usage) { puts "#{usage.provider}: #{usage.input_tokens} in / #{usage.output_tokens} out" }
|
|
50
58
|
end
|
|
@@ -55,8 +63,12 @@ end
|
|
|
55
63
|
- **OpenAI** - `Rasti::AI::OpenAI::Assistant`
|
|
56
64
|
- **Gemini** - `Rasti::AI::Gemini::Assistant`
|
|
57
65
|
- **Anthropic** - `Rasti::AI::Anthropic::Assistant`
|
|
66
|
+
- **OpenRouter** - `Rasti::AI::OpenRouter::Assistant`
|
|
67
|
+
- **Huawei MaaS** - `Rasti::AI::HuaweiMaaS::Assistant`
|
|
68
|
+
|
|
69
|
+
All providers share the same interface. The examples below use OpenAI, but apply equally to any provider by replacing `OpenAI` with the provider name.
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
OpenRouter and Huawei MaaS are [OpenAI-compatible](https://platform.openai.com/docs/api-reference/chat) APIs — they use the same chat completions format, tool calling, and Bearer token auth. Their clients and assistants inherit from the OpenAI provider, so everything in the examples below works identically.
|
|
60
72
|
|
|
61
73
|
### Assistant
|
|
62
74
|
|
|
@@ -428,9 +440,11 @@ assistant.call 'What is 5 plus 3?'
|
|
|
428
440
|
The gem includes interactive chat tasks wired to the [Pipeworx](https://pipeworx.io) public weather MCP server (no auth required):
|
|
429
441
|
|
|
430
442
|
```bash
|
|
431
|
-
OPENAI_API_KEY=sk-...
|
|
432
|
-
GEMINI_API_KEY=AIza...
|
|
433
|
-
ANTHROPIC_API_KEY=sk-...
|
|
443
|
+
OPENAI_API_KEY=sk-... rake assistant:openai
|
|
444
|
+
GEMINI_API_KEY=AIza... rake assistant:gemini
|
|
445
|
+
ANTHROPIC_API_KEY=sk-... rake assistant:anthropic
|
|
446
|
+
OPENROUTER_API_KEY=sk-or... rake assistant:openrouter
|
|
447
|
+
HUAWEI_MAAS_API_KEY=sk-... rake assistant:huawei_maas
|
|
434
448
|
```
|
|
435
449
|
|
|
436
450
|
Type your message and press Enter. Type `exit` or `Ctrl+C` to quit.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module HuaweiMaaS
|
|
4
|
+
class Client < Rasti::AI::OpenAI::Client
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def default_api_key
|
|
9
|
+
Rasti::AI.huawei_maas_api_key
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def default_model
|
|
13
|
+
Rasti::AI.huawei_maas_default_model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def provider_name
|
|
17
|
+
'huawei_maas'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def base_url
|
|
21
|
+
'https://api-ap-southeast-1.modelarts-maas.com/v2'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -5,7 +5,7 @@ module Rasti
|
|
|
5
5
|
|
|
6
6
|
def chat_completions(messages:, model:nil, tools:[], response_format:nil, reasoning_effort:nil)
|
|
7
7
|
body = {
|
|
8
|
-
model: model ||
|
|
8
|
+
model: model || default_model,
|
|
9
9
|
messages: messages,
|
|
10
10
|
tools: tools,
|
|
11
11
|
tool_choice: tools.empty? ? 'none' : 'auto'
|
|
@@ -23,7 +23,7 @@ module Rasti
|
|
|
23
23
|
usage = response['usage']
|
|
24
24
|
return unless usage
|
|
25
25
|
Usage.new(
|
|
26
|
-
provider:
|
|
26
|
+
provider: provider_name,
|
|
27
27
|
model: response['model'],
|
|
28
28
|
input_tokens: usage['prompt_tokens'],
|
|
29
29
|
output_tokens: usage['completion_tokens'],
|
|
@@ -37,6 +37,14 @@ module Rasti
|
|
|
37
37
|
Rasti::AI.openai_api_key
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def default_model
|
|
41
|
+
Rasti::AI.openai_default_model
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def provider_name
|
|
45
|
+
'open_ai'
|
|
46
|
+
end
|
|
47
|
+
|
|
40
48
|
def base_url
|
|
41
49
|
'https://api.openai.com/v1'
|
|
42
50
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module OpenRouter
|
|
4
|
+
class Client < Rasti::AI::OpenAI::Client
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def default_api_key
|
|
9
|
+
Rasti::AI.openrouter_api_key
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def default_model
|
|
13
|
+
Rasti::AI.openrouter_default_model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def provider_name
|
|
17
|
+
'open_router'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def base_url
|
|
21
|
+
'https://openrouter.ai/api/v1'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -66,7 +66,7 @@ module Rasti
|
|
|
66
66
|
when :integer then {type: 'integer'}
|
|
67
67
|
when :float then {type: 'number'}
|
|
68
68
|
when :boolean then {type: 'boolean'}
|
|
69
|
-
when :time then
|
|
69
|
+
when :time then json_schema_for_time_format(type_hash[:format])
|
|
70
70
|
when :enum then {type: 'string', enum: type_hash[:values]}
|
|
71
71
|
when :array then {type: 'array', items: json_schema_for_type(type_hash[:items])}
|
|
72
72
|
when :model then json_schema_from_model_schema(type_hash[:schema])
|
|
@@ -79,6 +79,21 @@ module Rasti
|
|
|
79
79
|
description.split("\n").map(&:strip).join(' ').strip
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
def json_schema_for_time_format(format)
|
|
83
|
+
return {type: 'string'} unless format.is_a?(::String)
|
|
84
|
+
|
|
85
|
+
has_date = format.include?('%F') || format.include?('%Y')
|
|
86
|
+
has_time = format.include?('%T') || format.include?('%H')
|
|
87
|
+
|
|
88
|
+
if has_date && has_time
|
|
89
|
+
{type: 'string', format: 'date-time'}
|
|
90
|
+
elsif has_date
|
|
91
|
+
{type: 'string', format: 'date'}
|
|
92
|
+
else
|
|
93
|
+
{type: 'string'}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
82
97
|
end
|
|
83
98
|
end
|
|
84
99
|
end
|
data/lib/rasti/ai/version.rb
CHANGED
data/lib/rasti/ai.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Rasti
|
|
|
21
21
|
require_relative 'ai/tool_serializer'
|
|
22
22
|
require_relative 'ai/client'
|
|
23
23
|
require_relative 'ai/assistant'
|
|
24
|
+
require_relative_pattern 'ai/open_ai/*'
|
|
24
25
|
require_relative_pattern 'ai/**/*'
|
|
25
26
|
|
|
26
27
|
attr_config :logger, Logger.new(STDOUT)
|
|
@@ -38,7 +39,13 @@ module Rasti
|
|
|
38
39
|
attr_config :anthropic_api_key, ENV['ANTHROPIC_API_KEY']
|
|
39
40
|
attr_config :anthropic_default_model, ENV['ANTHROPIC_DEFAULT_MODEL']
|
|
40
41
|
|
|
42
|
+
attr_config :openrouter_api_key, ENV['OPENROUTER_API_KEY']
|
|
43
|
+
attr_config :openrouter_default_model, ENV['OPENROUTER_DEFAULT_MODEL']
|
|
44
|
+
|
|
45
|
+
attr_config :huawei_maas_api_key, ENV['HUAWEI_MAAS_API_KEY']
|
|
46
|
+
attr_config :huawei_maas_default_model, ENV['HUAWEI_MAAS_DEFAULT_MODEL']
|
|
47
|
+
|
|
41
48
|
attr_config :usage_tracker, nil
|
|
42
49
|
|
|
43
50
|
end
|
|
44
|
-
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::HuaweiMaaS::Assistant do
|
|
4
|
+
|
|
5
|
+
let(:api_url) { 'https://api-ap-southeast-1.modelarts-maas.com/v2/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.huawei_maas_default_model, prompt: question))
|
|
14
|
+
.to_return(body: read_resource('open_ai/basic_response.json', content: answer))
|
|
15
|
+
|
|
16
|
+
assistant = Rasti::AI::HuaweiMaaS::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::HuaweiMaaS::Roles::USER,
|
|
60
|
+
content: question,
|
|
61
|
+
tools: [tool],
|
|
62
|
+
response: tool_response
|
|
63
|
+
|
|
64
|
+
stub_client_request role: Rasti::AI::HuaweiMaaS::Roles::TOOL,
|
|
65
|
+
content: tool_result,
|
|
66
|
+
tools: [tool],
|
|
67
|
+
response: basic_response(answer_with_tool)
|
|
68
|
+
|
|
69
|
+
assistant = Rasti::AI::HuaweiMaaS::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::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,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
|
|
@@ -115,7 +115,7 @@ describe Rasti::AI::ToolSerializer do
|
|
|
115
115
|
tool_class.verify
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
it 'Time' do
|
|
118
|
+
it 'Time with date and time' do
|
|
119
119
|
form_class = Rasti::Form[timestamp: Rasti::Types::Time['%Y-%m-%dT%H:%M:%S%z']]
|
|
120
120
|
|
|
121
121
|
tool_class = build_tool_class form_class
|
|
@@ -123,7 +123,37 @@ describe Rasti::AI::ToolSerializer do
|
|
|
123
123
|
serialization = serializer.serialize tool_class
|
|
124
124
|
|
|
125
125
|
expeted_serialization = build_serializaton param_name: 'timestamp', param_type: 'string'
|
|
126
|
-
expeted_serialization[:inputSchema][:properties][:timestamp][:format] = 'date'
|
|
126
|
+
expeted_serialization[:inputSchema][:properties][:timestamp][:format] = 'date-time'
|
|
127
|
+
|
|
128
|
+
assert_equal expeted_serialization, serialization
|
|
129
|
+
|
|
130
|
+
tool_class.verify
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'Time with milliseconds and timezone' do
|
|
134
|
+
form_class = Rasti::Form[started_at: Rasti::Types::Time['%FT%T.%L%z']]
|
|
135
|
+
|
|
136
|
+
tool_class = build_tool_class form_class
|
|
137
|
+
|
|
138
|
+
serialization = serializer.serialize tool_class
|
|
139
|
+
|
|
140
|
+
expeted_serialization = build_serializaton param_name: 'started_at', param_type: 'string'
|
|
141
|
+
expeted_serialization[:inputSchema][:properties][:started_at][:format] = 'date-time'
|
|
142
|
+
|
|
143
|
+
assert_equal expeted_serialization, serialization
|
|
144
|
+
|
|
145
|
+
tool_class.verify
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'Time with date only' do
|
|
149
|
+
form_class = Rasti::Form[day: Rasti::Types::Time['%Y-%m-%d']]
|
|
150
|
+
|
|
151
|
+
tool_class = build_tool_class form_class
|
|
152
|
+
|
|
153
|
+
serialization = serializer.serialize tool_class
|
|
154
|
+
|
|
155
|
+
expeted_serialization = build_serializaton param_name: 'day', param_type: 'string'
|
|
156
|
+
expeted_serialization[:inputSchema][:properties][:day][:format] = 'date'
|
|
127
157
|
|
|
128
158
|
assert_equal expeted_serialization, serialization
|
|
129
159
|
|
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
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rasti-ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gabriel Naiman
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: multi_require
|
|
@@ -260,6 +260,9 @@ files:
|
|
|
260
260
|
- lib/rasti/ai/gemini/assistant.rb
|
|
261
261
|
- lib/rasti/ai/gemini/client.rb
|
|
262
262
|
- lib/rasti/ai/gemini/roles.rb
|
|
263
|
+
- lib/rasti/ai/huawei_maas/assistant.rb
|
|
264
|
+
- lib/rasti/ai/huawei_maas/client.rb
|
|
265
|
+
- lib/rasti/ai/huawei_maas/roles.rb
|
|
263
266
|
- lib/rasti/ai/mcp/client.rb
|
|
264
267
|
- lib/rasti/ai/mcp/constants.rb
|
|
265
268
|
- lib/rasti/ai/mcp/server.rb
|
|
@@ -267,6 +270,9 @@ files:
|
|
|
267
270
|
- lib/rasti/ai/open_ai/assistant.rb
|
|
268
271
|
- lib/rasti/ai/open_ai/client.rb
|
|
269
272
|
- lib/rasti/ai/open_ai/roles.rb
|
|
273
|
+
- lib/rasti/ai/open_router/assistant.rb
|
|
274
|
+
- lib/rasti/ai/open_router/client.rb
|
|
275
|
+
- lib/rasti/ai/open_router/roles.rb
|
|
270
276
|
- lib/rasti/ai/tool.rb
|
|
271
277
|
- lib/rasti/ai/tool_serializer.rb
|
|
272
278
|
- lib/rasti/ai/usage.rb
|
|
@@ -278,12 +284,16 @@ files:
|
|
|
278
284
|
- spec/coverage_helper.rb
|
|
279
285
|
- spec/gemini/assistant_spec.rb
|
|
280
286
|
- spec/gemini/client_spec.rb
|
|
287
|
+
- spec/huawei_maas/assistant_spec.rb
|
|
288
|
+
- spec/huawei_maas/client_spec.rb
|
|
281
289
|
- spec/mcp/client_spec.rb
|
|
282
290
|
- spec/mcp/server_spec.rb
|
|
283
291
|
- spec/mcp/tools_registry_spec.rb
|
|
284
292
|
- spec/minitest_helper.rb
|
|
285
293
|
- spec/open_ai/assistant_spec.rb
|
|
286
294
|
- spec/open_ai/client_spec.rb
|
|
295
|
+
- spec/open_router/assistant_spec.rb
|
|
296
|
+
- spec/open_router/client_spec.rb
|
|
287
297
|
- spec/resources/anthropic/basic_request.json
|
|
288
298
|
- spec/resources/anthropic/basic_response.json
|
|
289
299
|
- spec/resources/anthropic/tool_request.json
|
|
@@ -329,12 +339,16 @@ test_files:
|
|
|
329
339
|
- spec/coverage_helper.rb
|
|
330
340
|
- spec/gemini/assistant_spec.rb
|
|
331
341
|
- spec/gemini/client_spec.rb
|
|
342
|
+
- spec/huawei_maas/assistant_spec.rb
|
|
343
|
+
- spec/huawei_maas/client_spec.rb
|
|
332
344
|
- spec/mcp/client_spec.rb
|
|
333
345
|
- spec/mcp/server_spec.rb
|
|
334
346
|
- spec/mcp/tools_registry_spec.rb
|
|
335
347
|
- spec/minitest_helper.rb
|
|
336
348
|
- spec/open_ai/assistant_spec.rb
|
|
337
349
|
- spec/open_ai/client_spec.rb
|
|
350
|
+
- spec/open_router/assistant_spec.rb
|
|
351
|
+
- spec/open_router/client_spec.rb
|
|
338
352
|
- spec/resources/anthropic/basic_request.json
|
|
339
353
|
- spec/resources/anthropic/basic_response.json
|
|
340
354
|
- spec/resources/anthropic/tool_request.json
|