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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f40d9534d2254ebcf7efe528f42b7187841ee5eb96ff4b406fcfd6a27a7bdf79
4
- data.tar.gz: 87a8eb4fbcae1b57ae77c4a9676424b80bb8fbfd8d29e61bb89425c0ed5d5d32
3
+ metadata.gz: 81ed3eb7239ec9d47de179db57b6d55abb21c512ee6c830de189c77692bb1bf0
4
+ data.tar.gz: fb25a1ab3f55861676625021c91bfed4aea5c40058c3b6282041dadd39c9fdb3
5
5
  SHA512:
6
- metadata.gz: cfc8303f52540e6c6058df5d7a7fe353e5bffd88a6b9122f789066877d0b8a87e7fb5daef1c78db740e54e159c2877dd6ea6540a080eed1a4feb2a1f466326ba
7
- data.tar.gz: 745f6ea9e9e2a95497514e1330feccebd3a7660709477f43fce3a6dfb03193d8f18f8012a44a976ed574dd4267d4b6979a59c970655e1a6580ef50291ccdd28d
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
- 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.
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-... rake assistant:openai
432
- GEMINI_API_KEY=AIza... rake assistant:gemini
433
- ANTHROPIC_API_KEY=sk-... rake assistant:anthropic
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,15 @@
1
+ module Rasti
2
+ module AI
3
+ module HuaweiMaaS
4
+ class Assistant < Rasti::AI::OpenAI::Assistant
5
+
6
+ private
7
+
8
+ def build_default_client
9
+ Client.new
10
+ end
11
+
12
+ end
13
+ end
14
+ end
15
+ end
@@ -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
@@ -0,0 +1,14 @@
1
+ module Rasti
2
+ module AI
3
+ module HuaweiMaaS
4
+ module Roles
5
+
6
+ ASSISTANT = 'assistant'.freeze
7
+ SYSTEM = 'system'.freeze
8
+ TOOL = 'tool'.freeze
9
+ USER = 'user'.freeze
10
+
11
+ end
12
+ end
13
+ end
14
+ 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 || Rasti::AI.openai_default_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: 'open_ai',
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,15 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenRouter
4
+ class Assistant < Rasti::AI::OpenAI::Assistant
5
+
6
+ private
7
+
8
+ def build_default_client
9
+ Client.new
10
+ end
11
+
12
+ end
13
+ end
14
+ end
15
+ 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
@@ -0,0 +1,14 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenRouter
4
+ module Roles
5
+
6
+ ASSISTANT = 'assistant'.freeze
7
+ SYSTEM = 'system'.freeze
8
+ TOOL = 'tool'.freeze
9
+ USER = 'user'.freeze
10
+
11
+ end
12
+ end
13
+ end
14
+ 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 {type: 'string', format: 'date'}
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
@@ -1,5 +1,5 @@
1
1
  module Rasti
2
2
  module AI
3
- VERSION = '3.0.0'
3
+ VERSION = '3.1.0'
4
4
  end
5
5
  end
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
@@ -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' => {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 }}
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.0.0
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-06-23 00:00:00.000000000 Z
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