rasti-ai 3.1.0 → 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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.features/api-normalization/api-design.md +184 -0
  3. data/.features/api-normalization/overview.md +230 -0
  4. data/AGENTS.md +125 -15
  5. data/README.md +143 -19
  6. data/lib/rasti/ai/anthropic/assistant.rb +1 -11
  7. data/lib/rasti/ai/anthropic/client.rb +6 -28
  8. data/lib/rasti/ai/anthropic/provider.rb +87 -0
  9. data/lib/rasti/ai/assistant.rb +10 -11
  10. data/lib/rasti/ai/client.rb +13 -10
  11. data/lib/rasti/ai/errors.rb +8 -0
  12. data/lib/rasti/ai/gemini/assistant.rb +1 -11
  13. data/lib/rasti/ai/gemini/client.rb +2 -24
  14. data/lib/rasti/ai/gemini/provider.rb +90 -0
  15. data/lib/rasti/ai/huawei_maas/assistant.rb +0 -7
  16. data/lib/rasti/ai/huawei_maas/client.rb +0 -19
  17. data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
  18. data/lib/rasti/ai/huawei_maas/roles.rb +1 -8
  19. data/lib/rasti/ai/open_ai/assistant.rb +0 -4
  20. data/lib/rasti/ai/open_ai/client.rb +3 -33
  21. data/lib/rasti/ai/open_ai/provider.rb +74 -0
  22. data/lib/rasti/ai/open_router/assistant.rb +0 -7
  23. data/lib/rasti/ai/open_router/client.rb +0 -19
  24. data/lib/rasti/ai/open_router/provider.rb +25 -0
  25. data/lib/rasti/ai/open_router/roles.rb +1 -8
  26. data/lib/rasti/ai/provider.rb +197 -0
  27. data/lib/rasti/ai/provider_aware.rb +17 -0
  28. data/lib/rasti/ai/result.rb +11 -0
  29. data/lib/rasti/ai/roles.rb +11 -0
  30. data/lib/rasti/ai/version.rb +1 -1
  31. data/lib/rasti/ai.rb +93 -0
  32. data/spec/anthropic/provider_spec.rb +106 -0
  33. data/spec/gemini/provider_spec.rb +90 -0
  34. data/spec/open_ai/provider_spec.rb +101 -0
  35. data/spec/rasti_ai_spec.rb +322 -0
  36. data/spec/resources/open_ai/conversation_request.json +1 -0
  37. data/spec/resources/open_ai/system_request.json +1 -0
  38. metadata +25 -2
@@ -2,13 +2,6 @@ module Rasti
2
2
  module AI
3
3
  module HuaweiMaaS
4
4
  class Assistant < Rasti::AI::OpenAI::Assistant
5
-
6
- private
7
-
8
- def build_default_client
9
- Client.new
10
- end
11
-
12
5
  end
13
6
  end
14
7
  end
@@ -2,25 +2,6 @@ module Rasti
2
2
  module AI
3
3
  module HuaweiMaaS
4
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
5
  end
25
6
  end
26
7
  end
@@ -0,0 +1,25 @@
1
+ module Rasti
2
+ module AI
3
+ module HuaweiMaaS
4
+ class Provider < Rasti::AI::OpenAI::Provider
5
+
6
+ def name
7
+ :huawei_maas
8
+ end
9
+
10
+ def default_model
11
+ Rasti::AI.huawei_maas_default_model
12
+ end
13
+
14
+ def default_api_key
15
+ Rasti::AI.huawei_maas_api_key
16
+ end
17
+
18
+ def base_url
19
+ 'https://api-ap-southeast-1.modelarts-maas.com/v2'
20
+ end
21
+
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,14 +1,7 @@
1
1
  module Rasti
2
2
  module AI
3
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
4
+ Roles = OpenAI::Roles
12
5
  end
13
6
  end
14
7
  end
@@ -5,10 +5,6 @@ module Rasti
5
5
 
6
6
  private
7
7
 
8
- def build_default_client
9
- Client.new
10
- end
11
-
12
8
  def build_user_message(prompt)
13
9
  {role: Roles::USER, content: prompt}
14
10
  end
@@ -11,44 +11,14 @@ module Rasti
11
11
  tool_choice: tools.empty? ? 'none' : 'auto'
12
12
  }
13
13
 
14
- body[:response_format] = response_format unless response_format.nil?
15
- body[:reasoning_effort] = reasoning_effort if reasoning_effort
14
+ body[:response_format] = response_format if response_format
15
+ body[:reasoning_effort] = reasoning_effort if reasoning_effort
16
16
 
17
17
  post '/chat/completions', body
18
18
  end
19
19
 
20
20
  private
21
21
 
22
- def parse_usage(response)
23
- usage = response['usage']
24
- return unless usage
25
- Usage.new(
26
- provider: provider_name,
27
- model: response['model'],
28
- input_tokens: usage['prompt_tokens'],
29
- output_tokens: usage['completion_tokens'],
30
- cached_tokens: usage.dig('prompt_tokens_details', 'cached_tokens') || 0,
31
- reasoning_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens') || 0,
32
- raw: usage
33
- )
34
- end
35
-
36
- def default_api_key
37
- Rasti::AI.openai_api_key
38
- end
39
-
40
- def default_model
41
- Rasti::AI.openai_default_model
42
- end
43
-
44
- def provider_name
45
- 'open_ai'
46
- end
47
-
48
- def base_url
49
- 'https://api.openai.com/v1'
50
- end
51
-
52
22
  def build_request(uri)
53
23
  request = super
54
24
  request['Authorization'] = "Bearer #{api_key}"
@@ -58,4 +28,4 @@ module Rasti
58
28
  end
59
29
  end
60
30
  end
61
- end
31
+ end
@@ -0,0 +1,74 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ class Provider < Rasti::AI::Provider
5
+
6
+ THINKING_LEVELS = {
7
+ 'low' => 'low'.freeze,
8
+ 'medium' => 'medium'.freeze,
9
+ 'high' => 'high'.freeze
10
+ }.freeze
11
+
12
+ def name
13
+ :open_ai
14
+ end
15
+
16
+ def default_model
17
+ Rasti::AI.openai_default_model
18
+ end
19
+
20
+ def default_api_key
21
+ Rasti::AI.openai_api_key
22
+ end
23
+
24
+ def base_url
25
+ 'https://api.openai.com/v1'
26
+ end
27
+
28
+ def parse_usage(response)
29
+ usage = response['usage']
30
+ return nil unless usage
31
+
32
+ Usage.new(
33
+ provider: name.to_s,
34
+ model: response['model'],
35
+ input_tokens: usage['prompt_tokens'],
36
+ output_tokens: usage['completion_tokens'],
37
+ cached_tokens: usage.dig('prompt_tokens_details', 'cached_tokens') || 0,
38
+ reasoning_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens') || 0,
39
+ raw: usage
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
46
+ all_messages = system ? [{role: Roles::SYSTEM, content: system}] + messages : messages
47
+
48
+ client.chat_completions messages: all_messages,
49
+ model: model,
50
+ response_format: response_format(json_schema),
51
+ reasoning_effort: thinking
52
+ end
53
+
54
+ def encode_message(message)
55
+ {role: message[:role], content: message[:content]}
56
+ end
57
+
58
+ def parse_content(response)
59
+ response.dig 'choices', 0, 'message', 'content'
60
+ end
61
+
62
+ def response_format(json_schema)
63
+ return nil if json_schema.nil?
64
+
65
+ {
66
+ type: 'json_schema',
67
+ json_schema: json_schema
68
+ }
69
+ end
70
+
71
+ end
72
+ end
73
+ end
74
+ end
@@ -2,13 +2,6 @@ module Rasti
2
2
  module AI
3
3
  module OpenRouter
4
4
  class Assistant < Rasti::AI::OpenAI::Assistant
5
-
6
- private
7
-
8
- def build_default_client
9
- Client.new
10
- end
11
-
12
5
  end
13
6
  end
14
7
  end
@@ -2,25 +2,6 @@ module Rasti
2
2
  module AI
3
3
  module OpenRouter
4
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
5
  end
25
6
  end
26
7
  end
@@ -0,0 +1,25 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenRouter
4
+ class Provider < Rasti::AI::OpenAI::Provider
5
+
6
+ def name
7
+ :open_router
8
+ end
9
+
10
+ def default_model
11
+ Rasti::AI.openrouter_default_model
12
+ end
13
+
14
+ def default_api_key
15
+ Rasti::AI.openrouter_api_key
16
+ end
17
+
18
+ def base_url
19
+ 'https://openrouter.ai/api/v1'
20
+ end
21
+
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,14 +1,7 @@
1
1
  module Rasti
2
2
  module AI
3
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
4
+ Roles = OpenAI::Roles
12
5
  end
13
6
  end
14
7
  end
@@ -0,0 +1,197 @@
1
+ module Rasti
2
+ module AI
3
+ class Provider
4
+
5
+ MODULES = {
6
+ open_ai: 'OpenAI',
7
+ gemini: 'Gemini',
8
+ anthropic: 'Anthropic',
9
+ open_router: 'OpenRouter',
10
+ huawei_maas: 'HuaweiMaaS'
11
+ }.freeze
12
+
13
+ ALIASES = {
14
+ openai: :open_ai,
15
+ openrouter: :open_router,
16
+ huawei: :huawei_maas
17
+ }.freeze
18
+
19
+ class << self
20
+
21
+ def build(name, **options)
22
+ return name if name.is_a? Provider
23
+ provider_class(name).new(**options)
24
+ end
25
+
26
+ def names
27
+ MODULES.keys
28
+ end
29
+
30
+ private
31
+
32
+ def provider_class(name)
33
+ key = normalize name
34
+ key = ALIASES.fetch key, key
35
+
36
+ raise Errors::UnknownProvider.new(name, names) unless MODULES.key? key
37
+
38
+ AI.const_get(MODULES[key])::Provider
39
+ end
40
+
41
+ def normalize(name)
42
+ name.to_s.downcase.to_sym
43
+ end
44
+
45
+ end
46
+
47
+ attr_reader :model
48
+
49
+ def initialize(model:nil, api_key:nil, usage_tracker:nil, logger:nil,
50
+ http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil)
51
+
52
+ @model = model || default_model
53
+ @api_key = api_key || default_api_key
54
+ @usage_tracker = usage_tracker
55
+ @logger = logger
56
+ @http_connect_timeout = http_connect_timeout
57
+ @http_read_timeout = http_read_timeout
58
+ @http_max_retries = http_max_retries
59
+ end
60
+
61
+ def name
62
+ raise NotImplementedError
63
+ end
64
+
65
+ def default_model
66
+ raise NotImplementedError
67
+ end
68
+
69
+ def default_api_key
70
+ raise NotImplementedError
71
+ end
72
+
73
+ def base_url
74
+ raise NotImplementedError
75
+ end
76
+
77
+ def parse_usage(response)
78
+ raise NotImplementedError
79
+ end
80
+
81
+ def build_client
82
+ client_class.new(
83
+ provider: self,
84
+ api_key: api_key,
85
+ logger: logger,
86
+ usage_tracker: usage_tracker,
87
+ http_connect_timeout: http_connect_timeout,
88
+ http_read_timeout: http_read_timeout,
89
+ http_max_retries: http_max_retries
90
+ )
91
+ end
92
+
93
+ def create_assistant(state:nil, system:nil, model:nil, tools:[], mcp_servers:{},
94
+ json_schema:nil, thinking:nil, client:nil)
95
+
96
+ raise ArgumentError, 'Use state or system, not both' if state && system
97
+
98
+ state = AssistantState.new(context: system) if state.nil? && system
99
+
100
+ assistant_class.new(
101
+ provider: self,
102
+ client: client,
103
+ model: model || self.model,
104
+ state: state,
105
+ tools: tools,
106
+ mcp_servers: mcp_servers,
107
+ json_schema: json_schema,
108
+ thinking: thinking,
109
+ logger: logger
110
+ )
111
+ end
112
+
113
+ def generate_text(prompt:nil, messages:nil, system:nil, model:nil,
114
+ json_schema:nil, thinking:nil, client:nil)
115
+
116
+ raise ArgumentError, 'Use prompt or messages, not both' if prompt && messages
117
+ raise ArgumentError, 'Undefined prompt or messages' if prompt.nil? && messages.nil?
118
+
119
+ system_prompt, conversation = split_system(messages || [{role: Roles::USER, content: prompt}])
120
+
121
+ client ||= build_client
122
+
123
+ response = request(
124
+ client: client,
125
+ messages: conversation.map { |message| encode_message message },
126
+ system: system || system_prompt,
127
+ model: model || self.model,
128
+ json_schema: json_schema,
129
+ thinking: thinking_config(thinking)
130
+ )
131
+
132
+ Result.new content: parse_content(response),
133
+ usage: parse_usage(response),
134
+ raw: response
135
+ end
136
+
137
+ private
138
+
139
+ attr_reader :api_key, :usage_tracker, :logger,
140
+ :http_connect_timeout, :http_read_timeout, :http_max_retries
141
+
142
+ def provider_module
143
+ AI.const_get MODULES.fetch(name)
144
+ end
145
+
146
+ def client_class
147
+ provider_module::Client
148
+ end
149
+
150
+ def assistant_class
151
+ provider_module::Assistant
152
+ end
153
+
154
+ def split_system(messages)
155
+ normalized = messages.map { |message| normalize_message message }
156
+
157
+ system_messages, conversation = normalized.partition { |message| message[:role] == Roles::SYSTEM }
158
+
159
+ system_prompt = system_messages.map { |message| message[:content] }.join("\n") unless system_messages.empty?
160
+
161
+ [system_prompt, conversation]
162
+ end
163
+
164
+ def normalize_message(message)
165
+ {
166
+ role: (message[:role] || message['role'] || Roles::USER).to_s,
167
+ content: message[:content] || message['content']
168
+ }
169
+ end
170
+
171
+ def thinking_config(level)
172
+ return nil if level.nil?
173
+
174
+ levels = self.class::THINKING_LEVELS
175
+
176
+ raise ArgumentError, "Invalid thinking level '#{level}'. Valid: #{levels.keys.join(', ')}" unless levels.key? level
177
+
178
+ levels[level]
179
+ end
180
+
181
+ # --- Template methods ---
182
+
183
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
184
+ raise NotImplementedError
185
+ end
186
+
187
+ def encode_message(message)
188
+ raise NotImplementedError
189
+ end
190
+
191
+ def parse_content(response)
192
+ raise NotImplementedError
193
+ end
194
+
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,17 @@
1
+ module Rasti
2
+ module AI
3
+ module ProviderAware
4
+
5
+ private
6
+
7
+ def provider
8
+ @provider ||= provider_module::Provider.new
9
+ end
10
+
11
+ def provider_module
12
+ Object.const_get self.class.name.split('::')[0..-2].join('::')
13
+ end
14
+
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Rasti
2
+ module AI
3
+ class Result < Rasti::Model
4
+
5
+ attribute :content, Rasti::Types::String
6
+ attribute :usage
7
+ attribute :raw
8
+
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Rasti
2
+ module AI
3
+ module Roles
4
+
5
+ ASSISTANT = 'assistant'.freeze
6
+ SYSTEM = 'system'.freeze
7
+ USER = 'user'.freeze
8
+
9
+ end
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  module Rasti
2
2
  module AI
3
- VERSION = '3.1.0'
3
+ VERSION = '3.2.0'
4
4
  end
5
5
  end
data/lib/rasti/ai.rb CHANGED
@@ -15,12 +15,16 @@ module Rasti
15
15
  extend ClassConfig
16
16
 
17
17
  require_relative 'ai/errors'
18
+ require_relative 'ai/roles'
18
19
  require_relative 'ai/usage'
20
+ require_relative 'ai/result'
19
21
  require_relative 'ai/assistant_state'
20
22
  require_relative 'ai/tool'
21
23
  require_relative 'ai/tool_serializer'
24
+ require_relative 'ai/provider_aware'
22
25
  require_relative 'ai/client'
23
26
  require_relative 'ai/assistant'
27
+ require_relative 'ai/provider'
24
28
  require_relative_pattern 'ai/open_ai/*'
25
29
  require_relative_pattern 'ai/**/*'
26
30
 
@@ -30,6 +34,8 @@ module Rasti
30
34
  attr_config :http_read_timeout, 60
31
35
  attr_config :http_max_retries, 3
32
36
 
37
+ attr_config :default_provider, ENV['AI_DEFAULT_PROVIDER']
38
+
33
39
  attr_config :openai_api_key, ENV['OPENAI_API_KEY']
34
40
  attr_config :openai_default_model, ENV['OPENAI_DEFAULT_MODEL']
35
41
 
@@ -47,5 +53,92 @@ module Rasti
47
53
 
48
54
  attr_config :usage_tracker, nil
49
55
 
56
+ class << self
57
+
58
+ def provider(name=nil, model:nil, api_key:nil, usage_tracker:nil, logger:nil,
59
+ http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil)
60
+
61
+ build_provider(
62
+ name, model, api_key, usage_tracker, logger,
63
+ http_connect_timeout, http_read_timeout, http_max_retries
64
+ ).first
65
+ end
66
+
67
+ def create_assistant(provider:nil, model:nil, api_key:nil, usage_tracker:nil, logger:nil,
68
+ http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil,
69
+ state:nil, system:nil, tools:[], mcp_servers:{}, json_schema:nil,
70
+ thinking:nil, client:nil)
71
+
72
+ resolved_provider, resolved_model = build_provider(
73
+ provider, model, api_key, usage_tracker, logger,
74
+ http_connect_timeout, http_read_timeout, http_max_retries
75
+ )
76
+
77
+ resolved_provider.create_assistant(
78
+ model: resolved_model,
79
+ state: state,
80
+ system: system,
81
+ tools: tools,
82
+ mcp_servers: mcp_servers,
83
+ json_schema: json_schema,
84
+ thinking: thinking,
85
+ client: client
86
+ )
87
+ end
88
+
89
+ def generate_text(provider:nil, model:nil, api_key:nil, usage_tracker:nil, logger:nil,
90
+ http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil,
91
+ prompt:nil, messages:nil, system:nil, json_schema:nil,
92
+ thinking:nil, client:nil)
93
+
94
+ resolved_provider, resolved_model = build_provider(
95
+ provider, model, api_key, usage_tracker, logger,
96
+ http_connect_timeout, http_read_timeout, http_max_retries
97
+ )
98
+
99
+ resolved_provider.generate_text(
100
+ model: resolved_model,
101
+ prompt: prompt,
102
+ messages: messages,
103
+ system: system,
104
+ json_schema: json_schema,
105
+ thinking: thinking,
106
+ client: client
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def build_provider(name, model, api_key, usage_tracker, logger,
113
+ http_connect_timeout, http_read_timeout, http_max_retries)
114
+
115
+ name, model = split_provider_and_model name, model
116
+
117
+ resolved_provider = Provider.build name,
118
+ model: model,
119
+ api_key: api_key,
120
+ usage_tracker: usage_tracker,
121
+ logger: logger,
122
+ http_connect_timeout: http_connect_timeout,
123
+ http_read_timeout: http_read_timeout,
124
+ http_max_retries: http_max_retries
125
+
126
+ [resolved_provider, model]
127
+ end
128
+
129
+ def split_provider_and_model(name, model)
130
+ if name.nil? && model.to_s.include?(':')
131
+ name, model = model.split ':', 2
132
+ end
133
+
134
+ name ||= default_provider
135
+
136
+ raise ArgumentError, "Undefined provider. Set it with provider: or Rasti::AI.default_provider. Valid providers: #{Provider.names.join(', ')}" if name.nil?
137
+
138
+ [name, model]
139
+ end
140
+
141
+ end
142
+
50
143
  end
51
144
  end