rasti-ai 3.1.0 → 3.2.1

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 (42) 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 +136 -19
  5. data/README.md +151 -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 +88 -0
  9. data/lib/rasti/ai/assistant.rb +11 -12
  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 +91 -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 +1 -5
  20. data/lib/rasti/ai/open_ai/client.rb +3 -33
  21. data/lib/rasti/ai/open_ai/provider.rb +75 -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/assistant_spec.rb +14 -0
  33. data/spec/anthropic/provider_spec.rb +121 -0
  34. data/spec/gemini/assistant_spec.rb +15 -0
  35. data/spec/gemini/provider_spec.rb +104 -0
  36. data/spec/open_ai/assistant_spec.rb +15 -0
  37. data/spec/open_ai/provider_spec.rb +111 -0
  38. data/spec/rasti_ai_spec.rb +322 -0
  39. data/spec/resources/open_ai/conversation_request.json +1 -0
  40. data/spec/resources/open_ai/system_request.json +1 -0
  41. data/tasks/assistant.rake +8 -8
  42. metadata +25 -2
@@ -0,0 +1,88 @@
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
+ 'none' => nil,
10
+ 'low' => {type: 'enabled', budget_tokens: 1_024}.freeze,
11
+ 'medium' => {type: 'enabled', budget_tokens: 8_000}.freeze,
12
+ 'high' => {type: 'enabled', budget_tokens: 16_000}.freeze
13
+ }.freeze
14
+
15
+ def name
16
+ :anthropic
17
+ end
18
+
19
+ def default_model
20
+ Rasti::AI.anthropic_default_model
21
+ end
22
+
23
+ def default_api_key
24
+ Rasti::AI.anthropic_api_key
25
+ end
26
+
27
+ def base_url
28
+ 'https://api.anthropic.com/v1'
29
+ end
30
+
31
+ def parse_usage(response)
32
+ usage = response['usage']
33
+ return nil unless usage
34
+
35
+ Usage.new(
36
+ provider: name.to_s,
37
+ model: response['model'],
38
+ input_tokens: usage['input_tokens'],
39
+ output_tokens: usage['output_tokens'],
40
+ cached_tokens: usage['cache_read_input_tokens'] || 0,
41
+ reasoning_tokens: 0,
42
+ raw: usage
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
49
+ tools = json_schema ? [structured_output_tool(json_schema)] : []
50
+ tool_choice = json_schema ? {type: 'tool', name: STRUCTURED_OUTPUT_TOOL} : nil
51
+
52
+ client.messages messages: messages,
53
+ model: model,
54
+ system: system,
55
+ tools: tools,
56
+ tool_choice: tool_choice,
57
+ thinking: thinking
58
+ end
59
+
60
+ def encode_message(message)
61
+ {role: message[:role], content: message[:content]}
62
+ end
63
+
64
+ def parse_content(response)
65
+ content = response['content'] || []
66
+
67
+ structured = content.find { |block| block['type'] == 'tool_use' && block['name'] == STRUCTURED_OUTPUT_TOOL }
68
+ return JSON.dump(structured['input']) if structured
69
+
70
+ text_block = content.find { |block| block['type'] == 'text' }
71
+ text_block['text'] if text_block
72
+ end
73
+
74
+ def structured_output_tool(json_schema)
75
+ {
76
+ name: STRUCTURED_OUTPUT_TOOL,
77
+ description: 'Return the structured response',
78
+ input_schema: {
79
+ type: 'object',
80
+ properties: json_schema
81
+ }
82
+ }
83
+ end
84
+
85
+ end
86
+ end
87
+ end
88
+ 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
- VALID_THINKING_LEVELS = %w[low medium high].freeze
9
+ VALID_THINKING_LEVELS = %w[none 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,91 @@
1
+ module Rasti
2
+ module AI
3
+ module Gemini
4
+ class Provider < Rasti::AI::Provider
5
+
6
+ THINKING_LEVELS = {
7
+ 'none' => {thinking_budget: 0}.freeze,
8
+ 'low' => {thinking_budget: 1_024}.freeze,
9
+ 'medium' => {thinking_budget: 8_192}.freeze,
10
+ 'high' => {thinking_budget: 24_576}.freeze
11
+ }.freeze
12
+
13
+ def name
14
+ :gemini
15
+ end
16
+
17
+ def default_model
18
+ Rasti::AI.gemini_default_model
19
+ end
20
+
21
+ def default_api_key
22
+ Rasti::AI.gemini_api_key
23
+ end
24
+
25
+ def base_url
26
+ 'https://generativelanguage.googleapis.com/v1beta'
27
+ end
28
+
29
+ def parse_usage(response)
30
+ usage = response['usageMetadata']
31
+ return nil unless usage
32
+
33
+ Usage.new(
34
+ provider: name.to_s,
35
+ model: response['modelVersion'],
36
+ input_tokens: usage['promptTokenCount'],
37
+ output_tokens: usage['candidatesTokenCount'],
38
+ cached_tokens: usage['cachedContentTokenCount'] || 0,
39
+ reasoning_tokens: usage['thoughtsTokenCount'] || 0,
40
+ raw: usage
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
47
+ client.generate_content contents: messages,
48
+ model: model,
49
+ system_instruction: system_instruction(system),
50
+ generation_config: generation_config(json_schema, thinking)
51
+ end
52
+
53
+ def encode_message(message)
54
+ {
55
+ role: encode_role(message[:role]),
56
+ parts: [{text: message[:content]}]
57
+ }
58
+ end
59
+
60
+ def encode_role(role)
61
+ role == Rasti::AI::Roles::ASSISTANT ? Roles::MODEL : Roles::USER
62
+ end
63
+
64
+ def parse_content(response)
65
+ parts = response.dig('candidates', 0, 'content', 'parts') || []
66
+ text_part = parts.find { |part| part.key? 'text' }
67
+ text_part['text'] if text_part
68
+ end
69
+
70
+ def system_instruction(system)
71
+ return nil if system.nil?
72
+ {parts: [{text: system}]}
73
+ end
74
+
75
+ def generation_config(json_schema, thinking)
76
+ config = {}
77
+
78
+ config[:thinking_config] = thinking if thinking
79
+
80
+ if json_schema
81
+ config[:response_mime_type] = 'application/json'
82
+ config[:response_schema] = json_schema
83
+ end
84
+
85
+ config.empty? ? nil : config
86
+ end
87
+
88
+ end
89
+ end
90
+ end
91
+ end
@@ -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
@@ -41,7 +37,7 @@ module Rasti
41
37
  end
42
38
 
43
39
  def thinking_config
44
- thinking
40
+ Provider::THINKING_LEVELS[thinking]
45
41
  end
46
42
 
47
43
  def parse_tool_calls(response)
@@ -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,75 @@
1
+ module Rasti
2
+ module AI
3
+ module OpenAI
4
+ class Provider < Rasti::AI::Provider
5
+
6
+ THINKING_LEVELS = {
7
+ 'none' => 'none'.freeze,
8
+ 'low' => 'low'.freeze,
9
+ 'medium' => 'medium'.freeze,
10
+ 'high' => 'high'.freeze
11
+ }.freeze
12
+
13
+ def name
14
+ :open_ai
15
+ end
16
+
17
+ def default_model
18
+ Rasti::AI.openai_default_model
19
+ end
20
+
21
+ def default_api_key
22
+ Rasti::AI.openai_api_key
23
+ end
24
+
25
+ def base_url
26
+ 'https://api.openai.com/v1'
27
+ end
28
+
29
+ def parse_usage(response)
30
+ usage = response['usage']
31
+ return nil unless usage
32
+
33
+ Usage.new(
34
+ provider: name.to_s,
35
+ model: response['model'],
36
+ input_tokens: usage['prompt_tokens'],
37
+ output_tokens: usage['completion_tokens'],
38
+ cached_tokens: usage.dig('prompt_tokens_details', 'cached_tokens') || 0,
39
+ reasoning_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens') || 0,
40
+ raw: usage
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def request(client:, messages:, system:, model:, json_schema:, thinking:)
47
+ all_messages = system ? [{role: Roles::SYSTEM, content: system}] + messages : messages
48
+
49
+ client.chat_completions messages: all_messages,
50
+ model: model,
51
+ response_format: response_format(json_schema),
52
+ reasoning_effort: thinking
53
+ end
54
+
55
+ def encode_message(message)
56
+ {role: message[:role], content: message[:content]}
57
+ end
58
+
59
+ def parse_content(response)
60
+ response.dig 'choices', 0, 'message', 'content'
61
+ end
62
+
63
+ def response_format(json_schema)
64
+ return nil if json_schema.nil?
65
+
66
+ {
67
+ type: 'json_schema',
68
+ json_schema: json_schema
69
+ }
70
+ end
71
+
72
+ end
73
+ end
74
+ end
75
+ 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