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.
Files changed (44) 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 +167 -10
  5. data/README.md +161 -23
  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 +8 -0
  16. data/lib/rasti/ai/huawei_maas/client.rb +8 -0
  17. data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
  18. data/lib/rasti/ai/huawei_maas/roles.rb +7 -0
  19. data/lib/rasti/ai/open_ai/assistant.rb +0 -4
  20. data/lib/rasti/ai/open_ai/client.rb +4 -26
  21. data/lib/rasti/ai/open_ai/provider.rb +74 -0
  22. data/lib/rasti/ai/open_router/assistant.rb +8 -0
  23. data/lib/rasti/ai/open_router/client.rb +8 -0
  24. data/lib/rasti/ai/open_router/provider.rb +25 -0
  25. data/lib/rasti/ai/open_router/roles.rb +7 -0
  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 +101 -1
  32. data/spec/anthropic/provider_spec.rb +106 -0
  33. data/spec/gemini/provider_spec.rb +90 -0
  34. data/spec/huawei_maas/assistant_spec.rb +80 -0
  35. data/spec/huawei_maas/client_spec.rb +60 -0
  36. data/spec/minitest_helper.rb +6 -0
  37. data/spec/open_ai/provider_spec.rb +101 -0
  38. data/spec/open_router/assistant_spec.rb +80 -0
  39. data/spec/open_router/client_spec.rb +60 -0
  40. data/spec/rasti_ai_spec.rb +322 -0
  41. data/spec/resources/open_ai/conversation_request.json +1 -0
  42. data/spec/resources/open_ai/system_request.json +1 -0
  43. data/tasks/assistant.rake +5 -3
  44. metadata +39 -2
@@ -0,0 +1,87 @@
1
+ module Rasti
2
+ module AI
3
+ module Anthropic
4
+ class Provider < Rasti::AI::Provider
5
+
6
+ STRUCTURED_OUTPUT_TOOL = 'structured_output'.freeze
7
+
8
+ THINKING_LEVELS = {
9
+ 'low' => {type: 'enabled', budget_tokens: 1_024}.freeze,
10
+ 'medium' => {type: 'enabled', budget_tokens: 8_000}.freeze,
11
+ 'high' => {type: 'enabled', budget_tokens: 16_000}.freeze
12
+ }.freeze
13
+
14
+ def name
15
+ :anthropic
16
+ end
17
+
18
+ def default_model
19
+ Rasti::AI.anthropic_default_model
20
+ end
21
+
22
+ def default_api_key
23
+ Rasti::AI.anthropic_api_key
24
+ end
25
+
26
+ def base_url
27
+ 'https://api.anthropic.com/v1'
28
+ end
29
+
30
+ def parse_usage(response)
31
+ usage = response['usage']
32
+ return nil unless usage
33
+
34
+ Usage.new(
35
+ provider: name.to_s,
36
+ model: response['model'],
37
+ input_tokens: usage['input_tokens'],
38
+ output_tokens: usage['output_tokens'],
39
+ cached_tokens: usage['cache_read_input_tokens'] || 0,
40
+ reasoning_tokens: 0,
41
+ raw: usage
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
48
+ tools = json_schema ? [structured_output_tool(json_schema)] : []
49
+ tool_choice = json_schema ? {type: 'tool', name: STRUCTURED_OUTPUT_TOOL} : nil
50
+
51
+ client.messages messages: messages,
52
+ model: model,
53
+ system: system,
54
+ tools: tools,
55
+ tool_choice: tool_choice,
56
+ thinking: thinking
57
+ end
58
+
59
+ def encode_message(message)
60
+ {role: message[:role], content: message[:content]}
61
+ end
62
+
63
+ def parse_content(response)
64
+ content = response['content'] || []
65
+
66
+ structured = content.find { |block| block['type'] == 'tool_use' && block['name'] == STRUCTURED_OUTPUT_TOOL }
67
+ return JSON.dump(structured['input']) if structured
68
+
69
+ text_block = content.find { |block| block['type'] == 'text' }
70
+ text_block['text'] if text_block
71
+ end
72
+
73
+ def structured_output_tool(json_schema)
74
+ {
75
+ name: STRUCTURED_OUTPUT_TOOL,
76
+ description: 'Return the structured response',
77
+ input_schema: {
78
+ type: 'object',
79
+ properties: json_schema
80
+ }
81
+ }
82
+ end
83
+
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,14 +2,17 @@ module Rasti
2
2
  module AI
3
3
  class Assistant
4
4
 
5
+ include ProviderAware
6
+
5
7
  attr_reader :state, :model, :thinking
6
8
 
7
9
  VALID_THINKING_LEVELS = %w[low medium high].freeze
8
10
 
9
- def initialize(client:nil, json_schema:nil, state:nil, model:nil, thinking:nil, tools:[], mcp_servers:{}, logger:nil)
11
+ def initialize(client:nil, provider:nil, json_schema:nil, state:nil, model:nil, thinking:nil, tools:[], mcp_servers:{}, logger:nil)
10
12
  raise ArgumentError, "Invalid thinking level '#{thinking}'. Valid: #{VALID_THINKING_LEVELS.join(', ')}" if thinking && !VALID_THINKING_LEVELS.include?(thinking)
11
13
 
12
- @client = client || build_default_client
14
+ @provider = provider
15
+ @client = client
13
16
  @json_schema = json_schema
14
17
  @state = state || AssistantState.new
15
18
  @model = model
@@ -50,14 +53,16 @@ module Rasti
50
53
 
51
54
  private
52
55
 
53
- attr_reader :client, :json_schema, :tools, :serialized_tools, :logger
56
+ attr_reader :json_schema, :tools, :serialized_tools, :logger
57
+
58
+ def client
59
+ @client ||= provider.build_client
60
+ end
54
61
 
55
62
  def messages
56
63
  state.messages
57
64
  end
58
65
 
59
- # --- Shared behavior ---
60
-
61
66
  def register_tools(tools)
62
67
  tools.each do |tool|
63
68
  serialization = wrap_tool_serialization(ToolSerializer.serialize(tool.class))
@@ -99,12 +104,6 @@ module Rasti
99
104
  "Error: #{ex.message}"
100
105
  end
101
106
 
102
- # --- Template methods ---
103
-
104
- def build_default_client
105
- raise NotImplementedError
106
- end
107
-
108
107
  def build_user_message(prompt)
109
108
  raise NotImplementedError
110
109
  end
@@ -2,9 +2,12 @@ module Rasti
2
2
  module AI
3
3
  class Client
4
4
 
5
+ include ProviderAware
6
+
5
7
  RETRYABLE_STATUS_CODES = [502, 503, 504].freeze
6
8
 
7
- def initialize(api_key:nil, logger:nil, http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil, usage_tracker:nil)
9
+ def initialize(provider:nil, api_key:nil, logger:nil, http_connect_timeout:nil, http_read_timeout:nil, http_max_retries:nil, usage_tracker:nil)
10
+ @provider = provider
8
11
  @api_key = api_key || default_api_key
9
12
  @logger = logger || Rasti::AI.logger
10
13
  @http_connect_timeout = http_connect_timeout || Rasti::AI.http_connect_timeout
@@ -17,22 +20,22 @@ module Rasti
17
20
 
18
21
  attr_reader :api_key, :logger, :http_connect_timeout, :http_read_timeout, :http_max_retries, :usage_tracker
19
22
 
20
- def track_usage(response)
21
- return unless usage_tracker
22
- usage = parse_usage response
23
- usage_tracker.call usage if usage
23
+ def default_api_key
24
+ provider.default_api_key
24
25
  end
25
26
 
26
- def parse_usage(response)
27
- raise NotImplementedError
27
+ def default_model
28
+ provider.default_model
28
29
  end
29
30
 
30
- def default_api_key
31
- raise NotImplementedError
31
+ def track_usage(response)
32
+ return unless usage_tracker
33
+ usage = provider.parse_usage response
34
+ usage_tracker.call usage if usage
32
35
  end
33
36
 
34
37
  def base_url
35
- raise NotImplementedError
38
+ provider.base_url
36
39
  end
37
40
 
38
41
  def build_url(relative_url)
@@ -34,6 +34,14 @@ module Rasti
34
34
 
35
35
  end
36
36
 
37
+ class UnknownProvider < StandardError
38
+
39
+ def initialize(provider, valid_providers)
40
+ super "Unknown provider #{provider.inspect}. Valid providers: #{valid_providers.join(', ')}"
41
+ end
42
+
43
+ end
44
+
37
45
  end
38
46
  end
39
47
  end
@@ -5,18 +5,8 @@ module Rasti
5
5
 
6
6
  ALLOWED_SCHEMA_FIELDS = %w[type description properties required enum items format nullable anyOf].freeze
7
7
 
8
- THINKING_LEVELS = {
9
- 'low' => {thinking_budget: 1_024}.freeze,
10
- 'medium' => {thinking_budget: 8_192}.freeze,
11
- 'high' => {thinking_budget: 24_576}.freeze
12
- }.freeze
13
-
14
8
  private
15
9
 
16
- def build_default_client
17
- Client.new
18
- end
19
-
20
10
  def build_user_message(prompt)
21
11
  {role: Roles::USER, parts: [{text: prompt}]}
22
12
  end
@@ -112,7 +102,7 @@ module Rasti
112
102
  end
113
103
 
114
104
  def thinking_config
115
- THINKING_LEVELS[thinking]
105
+ Provider::THINKING_LEVELS[thinking]
116
106
  end
117
107
 
118
108
  def generation_config
@@ -4,7 +4,7 @@ module Rasti
4
4
  class Client < Rasti::AI::Client
5
5
 
6
6
  def generate_content(contents:, model:nil, tools:[], system_instruction:nil, generation_config:nil)
7
- model_name = model || Rasti::AI.gemini_default_model
7
+ model_name = model || default_model
8
8
 
9
9
  body = {contents: contents}
10
10
 
@@ -17,30 +17,8 @@ module Rasti
17
17
 
18
18
  private
19
19
 
20
- def parse_usage(response)
21
- usage = response['usageMetadata']
22
- return unless usage
23
- Usage.new(
24
- provider: 'gemini',
25
- model: response['modelVersion'],
26
- input_tokens: usage['promptTokenCount'],
27
- output_tokens: usage['candidatesTokenCount'],
28
- cached_tokens: usage['cachedContentTokenCount'] || 0,
29
- reasoning_tokens: usage['thoughtsTokenCount'] || 0,
30
- raw: usage
31
- )
32
- end
33
-
34
- def default_api_key
35
- Rasti::AI.gemini_api_key
36
- end
37
-
38
- def base_url
39
- 'https://generativelanguage.googleapis.com/v1beta'
40
- end
41
-
42
20
  def build_url(relative_url)
43
- "#{base_url}#{relative_url}?key=#{api_key}"
21
+ "#{super}?key=#{api_key}"
44
22
  end
45
23
 
46
24
  end
@@ -0,0 +1,90 @@
1
+ module Rasti
2
+ module AI
3
+ module Gemini
4
+ class Provider < Rasti::AI::Provider
5
+
6
+ THINKING_LEVELS = {
7
+ 'low' => {thinking_budget: 1_024}.freeze,
8
+ 'medium' => {thinking_budget: 8_192}.freeze,
9
+ 'high' => {thinking_budget: 24_576}.freeze
10
+ }.freeze
11
+
12
+ def name
13
+ :gemini
14
+ end
15
+
16
+ def default_model
17
+ Rasti::AI.gemini_default_model
18
+ end
19
+
20
+ def default_api_key
21
+ Rasti::AI.gemini_api_key
22
+ end
23
+
24
+ def base_url
25
+ 'https://generativelanguage.googleapis.com/v1beta'
26
+ end
27
+
28
+ def parse_usage(response)
29
+ usage = response['usageMetadata']
30
+ return nil unless usage
31
+
32
+ Usage.new(
33
+ provider: name.to_s,
34
+ model: response['modelVersion'],
35
+ input_tokens: usage['promptTokenCount'],
36
+ output_tokens: usage['candidatesTokenCount'],
37
+ cached_tokens: usage['cachedContentTokenCount'] || 0,
38
+ reasoning_tokens: usage['thoughtsTokenCount'] || 0,
39
+ raw: usage
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
46
+ client.generate_content contents: messages,
47
+ model: model,
48
+ system_instruction: system_instruction(system),
49
+ generation_config: generation_config(json_schema, thinking)
50
+ end
51
+
52
+ def encode_message(message)
53
+ {
54
+ role: encode_role(message[:role]),
55
+ parts: [{text: message[:content]}]
56
+ }
57
+ end
58
+
59
+ def encode_role(role)
60
+ role == Rasti::AI::Roles::ASSISTANT ? Roles::MODEL : Roles::USER
61
+ end
62
+
63
+ def parse_content(response)
64
+ parts = response.dig('candidates', 0, 'content', 'parts') || []
65
+ text_part = parts.find { |part| part.key? 'text' }
66
+ text_part['text'] if text_part
67
+ end
68
+
69
+ def system_instruction(system)
70
+ return nil if system.nil?
71
+ {parts: [{text: system}]}
72
+ end
73
+
74
+ def generation_config(json_schema, thinking)
75
+ config = {}
76
+
77
+ config[:thinking_config] = thinking if thinking
78
+
79
+ if json_schema
80
+ config[:response_mime_type] = 'application/json'
81
+ config[:response_schema] = json_schema
82
+ end
83
+
84
+ config.empty? ? nil : config
85
+ end
86
+
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,8 @@
1
+ module Rasti
2
+ module AI
3
+ module HuaweiMaaS
4
+ class Assistant < Rasti::AI::OpenAI::Assistant
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Rasti
2
+ module AI
3
+ module HuaweiMaaS
4
+ class Client < Rasti::AI::OpenAI::Client
5
+ end
6
+ end
7
+ end
8
+ 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
@@ -0,0 +1,7 @@
1
+ module Rasti
2
+ module AI
3
+ module HuaweiMaaS
4
+ Roles = OpenAI::Roles
5
+ end
6
+ end
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
@@ -5,42 +5,20 @@ 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'
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: 'open_ai',
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 base_url
41
- 'https://api.openai.com/v1'
42
- end
43
-
44
22
  def build_request(uri)
45
23
  request = super
46
24
  request['Authorization'] = "Bearer #{api_key}"
@@ -50,4 +28,4 @@ module Rasti
50
28
  end
51
29
  end
52
30
  end
53
- 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
@@ -0,0 +1,8 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenRouter
4
+ class Assistant < Rasti::AI::OpenAI::Assistant
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenRouter
4
+ class Client < Rasti::AI::OpenAI::Client
5
+ end
6
+ end
7
+ end
8
+ 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
@@ -0,0 +1,7 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenRouter
4
+ Roles = OpenAI::Roles
5
+ end
6
+ end
7
+ end