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
data/README.md CHANGED
@@ -53,6 +53,9 @@ Rasti::AI.configure do |config|
53
53
  config.huawei_maas_api_key = 'sk-maas-12345' # Default ENV['HUAWEI_MAAS_API_KEY']
54
54
  config.huawei_maas_default_model = 'DeepSeek-V3' # Default ENV['HUAWEI_MAAS_DEFAULT_MODEL']
55
55
 
56
+ # Default provider used by Rasti::AI.create_assistant and Rasti::AI.generate_text
57
+ config.default_provider = :open_ai # Default ENV['AI_DEFAULT_PROVIDER']
58
+
56
59
  # Usage tracking
57
60
  config.usage_tracker = ->(usage) { puts "#{usage.provider}: #{usage.input_tokens} in / #{usage.output_tokens} out" }
58
61
  end
@@ -70,10 +73,132 @@ All providers share the same interface. The examples below use OpenAI, but apply
70
73
 
71
74
  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.
72
75
 
76
+ ### Choosing provider and model at runtime
77
+
78
+ Instead of referencing a provider-specific class, the provider can be a parameter or come from the configuration. This makes swapping models a config change instead of a code change.
79
+
80
+ ```ruby
81
+ # One-shot text generation
82
+ result = Rasti::AI.generate_text prompt: 'who is the best player', provider: :open_ai
83
+
84
+ result.content # => 'The best player is Lionel Messi'
85
+ result.usage # => Rasti::AI::Usage
86
+ result.raw # => raw provider response
87
+
88
+ # Provider and model
89
+ Rasti::AI.generate_text prompt: 'who is the best player', provider: :anthropic, model: 'claude-sonnet-4-5'
90
+
91
+ # Both in a single string — handy for ENV vars
92
+ Rasti::AI.generate_text prompt: 'who is the best player', model: ENV['AI_MODEL'] # 'gemini:gemini-2.0-flash'
93
+
94
+ # Provider from configuration (Rasti::AI.default_provider)
95
+ Rasti::AI.generate_text prompt: 'who is the best player'
96
+ ```
97
+
98
+ The response is normalized across providers: `content` is the text the model returned (or the JSON string when `json_schema:` is used), `usage` is a `Rasti::AI::Usage`, and `raw` is the untouched provider payload as an escape hatch.
99
+
100
+ Instead of a single `prompt:`, a conversation can be passed with `messages:` using generic roles — each provider translates them to its own format (`assistant` becomes `model` on Gemini, `system` becomes `system_instruction`, etc.):
101
+
102
+ ```ruby
103
+ Rasti::AI.generate_text provider: :gemini, messages: [
104
+ {role: Rasti::AI::Roles::SYSTEM, content: 'Act as sports journalist'},
105
+ {role: Rasti::AI::Roles::USER, content: 'who is the best player'},
106
+ {role: Rasti::AI::Roles::ASSISTANT, content: 'Lionel Messi'},
107
+ {role: Rasti::AI::Roles::USER, content: 'how many goals did he score?'}
108
+ ]
109
+ ```
110
+
111
+ `generate_text` is a single request without tools. For tool calling, multi-turn conversations and stateful interactions use an assistant.
112
+
113
+ ### Assistants — tool calling and multi-turn conversations
114
+
115
+ `Rasti::AI.create_assistant` is the primary entry point for assistants. It resolves the provider at runtime — just like `generate_text` — and returns the provider-specific `Assistant` instance, so swapping models is a config change instead of a code change. Everything documented below (tools, state, structured responses, thinking, MCP) works the same across all providers:
116
+
117
+ ```ruby
118
+ assistant = Rasti::AI.create_assistant provider: :open_ai,
119
+ model: 'gpt-4o',
120
+ system: 'Act as sports journalist',
121
+ tools: [GetCurrentWeather.new],
122
+ mcp_servers: {weather: mcp_client},
123
+ thinking: 'medium'
124
+
125
+ assistant.call 'who is the best player' # => 'The best player is Lionel Messi'
126
+ assistant # => #<Rasti::AI::OpenAI::Assistant>
127
+ ```
128
+
129
+ Omit `provider:` to use `Rasti::AI.default_provider`, or pass `'provider:model'` as `model:` to set both in one string — handy for ENV-driven configuration:
130
+
131
+ ```ruby
132
+ assistant = Rasti::AI.create_assistant model: ENV['AI_MODEL'], # 'anthropic:claude-sonnet-4-5'
133
+ system: 'Act as sports journalist',
134
+ tools: [GetCurrentWeather.new]
135
+ ```
136
+
137
+ Both methods accept the same options:
138
+
139
+ | Option | Purpose | `generate_text` | `create_assistant` |
140
+ |---|---|---|---|
141
+ | `provider:` | `:open_ai`, `:gemini`, `:anthropic`, `:open_router`, `:huawei_maas` (aliases: `:openai`, `:openrouter`, `:huawei`). Defaults to `Rasti::AI.default_provider` | ✓ | ✓ |
142
+ | `model:` | Model name, or `'provider:model'` to set both. Defaults to the provider's configured default model | ✓ | ✓ |
143
+ | `api_key:` | Overrides the configured API key — useful for multi-tenant apps | ✓ | ✓ |
144
+ | `usage_tracker:` | Per-call usage tracker lambda | ✓ | ✓ |
145
+ | `logger:` | Overrides the configured logger | ✓ | ✓ |
146
+ | `http_connect_timeout:`, `http_read_timeout:`, `http_max_retries:` | Per-call HTTP settings | ✓ | ✓ |
147
+ | `client:` | A pre-built client (skips building one from the options above) | ✓ | ✓ |
148
+ | `json_schema:` | Schema for structured responses | ✓ | ✓ |
149
+ | `thinking:` | `'low'`, `'medium'` or `'high'` | ✓ | ✓ |
150
+ | `prompt:` | A single user message | ✓ | |
151
+ | `messages:` | Conversation with generic roles — mutually exclusive with `prompt:` | ✓ | |
152
+ | `system:` | System prompt. On `create_assistant` it's a shortcut for `state: AssistantState.new(context: ...)` | ✓ | ✓ |
153
+ | `state:` | An existing `AssistantState` to continue a conversation | | ✓ |
154
+ | `tools:` | Array of tool instances | | ✓ |
155
+ | `mcp_servers:` | Hash of `{name => MCP::Client}` | | ✓ |
156
+
157
+ ### Provider
158
+
159
+ A provider bundles everything needed to reach a model: credentials, default model, usage tracking and HTTP settings. `Rasti::AI.provider` returns a reusable instance — useful when the same credentials are used for many calls, e.g. per tenant:
160
+
161
+ ```ruby
162
+ provider = Rasti::AI.provider :anthropic,
163
+ model: 'claude-sonnet-4-5',
164
+ api_key: tenant.anthropic_key,
165
+ usage_tracker: ->(usage) { tenant.track usage },
166
+ http_read_timeout: 300
167
+
168
+ provider.name # => :anthropic
169
+ provider.model # => 'claude-sonnet-4-5'
170
+
171
+ provider.generate_text prompt: 'who is the best player' # => Rasti::AI::Result
172
+ provider.generate_text prompt: '...', model: 'claude-opus-4-5' # per call model override
173
+ provider.create_assistant tools: [GetCurrentWeather.new] # => Rasti::AI::Anthropic::Assistant
174
+ provider.build_client # => Rasti::AI::Anthropic::Client
175
+ ```
176
+
177
+ The available providers are fixed: `Rasti::AI::Provider.names # => [:open_ai, :gemini, :anthropic, :open_router, :huawei_maas]`. An unknown name raises `Rasti::AI::Errors::UnknownProvider`.
178
+
179
+ A provider instance can also be passed directly as `provider:` to the module-level methods:
180
+
181
+ ```ruby
182
+ Rasti::AI.generate_text provider: provider, prompt: 'who is the best player'
183
+ ```
184
+
73
185
  ### Assistant
74
186
 
187
+ `Rasti::AI.create_assistant` is the recommended way to build an assistant — it keeps the provider as a runtime value and shares the same options as `generate_text`:
188
+
75
189
  ```ruby
76
- assistant = Rasti::AI::OpenAI::Assistant.new
190
+ assistant = Rasti::AI.create_assistant provider: :open_ai,
191
+ system: 'Act as sports journalist',
192
+ tools: [GetCurrentWeather.new]
193
+
194
+ assistant.call 'who is the best player' # => 'The best player is Lionel Messi'
195
+ ```
196
+
197
+ Provider-specific classes are also available and are fully equivalent — useful when the provider is fixed by the application:
198
+
199
+ ```ruby
200
+ assistant = Rasti::AI::OpenAI::Assistant.new system: 'Act as sports journalist',
201
+ tools: [GetCurrentWeather.new]
77
202
  assistant.call 'who is the best player' # => 'The best player is Lionel Messi'
78
203
  ```
79
204
 
@@ -138,7 +263,7 @@ tools = [
138
263
  GetCurrentWeather.new
139
264
  ]
140
265
 
141
- assistant = Rasti::AI::OpenAI::Assistant.new tools: tools
266
+ assistant = Rasti::AI.create_assistant provider: :open_ai, tools: tools
142
267
 
143
268
  assistant.call 'what time is it' # => 'The current time is 3:03 PM on April 28, 2025.'
144
269
 
@@ -149,7 +274,7 @@ assistant.call 'what is the weather in Buenos Aires' # => 'In Buenos Aires it is
149
274
  ```ruby
150
275
  state = Rasti::AI::AssistantState.new context: 'Act as sports journalist'
151
276
 
152
- assistant = Rasti::AI::OpenAI::Assistant.new state: state
277
+ assistant = Rasti::AI.create_assistant provider: :open_ai, state: state
153
278
 
154
279
  assistant.call 'who is the best player'
155
280
 
@@ -157,14 +282,15 @@ state.context # => 'Act as sports journalist'
157
282
  state.messages # Array of provider-specific message hashes
158
283
  ```
159
284
 
160
- The state keeps the conversation history, enabling multi-turn interactions. It also caches tool call results to avoid duplicate executions.
285
+ The state keeps the conversation history, enabling multi-turn interactions. It also caches tool call results to avoid duplicate executions. The `system:` option is a shortcut for `state: AssistantState.new(context: ...)`.
161
286
 
162
287
  ### Structured responses (JSON Schema)
163
288
  ```ruby
164
- assistant = Rasti::AI::OpenAI::Assistant.new json_schema: {
165
- player: 'string',
166
- sport: 'string'
167
- }
289
+ assistant = Rasti::AI.create_assistant provider: :open_ai,
290
+ json_schema: {
291
+ player: 'string',
292
+ sport: 'string'
293
+ }
168
294
 
169
295
  response = assistant.call 'who is the best player'
170
296
  JSON.parse response # => {"player" => "Lionel Messi", "sport" => "Football"}
@@ -173,7 +299,7 @@ JSON.parse response # => {"player" => "Lionel Messi", "sport" => "Football"}
173
299
  ### Custom model and client
174
300
  ```ruby
175
301
  # Override model
176
- assistant = Rasti::AI::OpenAI::Assistant.new model: 'gpt-4o'
302
+ assistant = Rasti::AI.create_assistant provider: :open_ai, model: 'gpt-4o'
177
303
 
178
304
  # Custom client with per-client HTTP settings
179
305
  client = Rasti::AI::OpenAI::Client.new(
@@ -182,15 +308,15 @@ client = Rasti::AI::OpenAI::Client.new(
182
308
  http_max_retries: 5
183
309
  )
184
310
 
185
- assistant = Rasti::AI::OpenAI::Assistant.new client: client
311
+ assistant = Rasti::AI.create_assistant provider: :open_ai, client: client
186
312
 
187
- # Anthropic client
313
+ # Anthropic client — Claude can be slow on long responses
188
314
  client = Rasti::AI::Anthropic::Client.new(
189
315
  http_connect_timeout: 120,
190
- http_read_timeout: 300 # Claude can be slow on long responses
316
+ http_read_timeout: 300
191
317
  )
192
318
 
193
- assistant = Rasti::AI::Anthropic::Assistant.new client: client
319
+ assistant = Rasti::AI.create_assistant provider: :anthropic, client: client
194
320
  ```
195
321
 
196
322
  ### Thinking / extended reasoning
@@ -198,7 +324,7 @@ assistant = Rasti::AI::Anthropic::Assistant.new client: client
198
324
  Some providers support extended reasoning ("thinking") to improve accuracy on complex tasks. Pass `thinking:` with a level of `'low'`, `'medium'`, or `'high'` when creating an assistant:
199
325
 
200
326
  ```ruby
201
- assistant = Rasti::AI::Anthropic::Assistant.new thinking: 'high'
327
+ assistant = Rasti::AI.create_assistant provider: :anthropic, thinking: 'high'
202
328
  assistant.call 'Solve this step by step: ...'
203
329
  ```
204
330
 
@@ -213,7 +339,7 @@ tracked_usage = []
213
339
  tracker = ->(usage) { tracked_usage << usage }
214
340
 
215
341
  client = Rasti::AI::OpenAI::Client.new usage_tracker: tracker
216
- assistant = Rasti::AI::OpenAI::Assistant.new client: client
342
+ assistant = Rasti::AI.create_assistant provider: :open_ai, client: client
217
343
  assistant.call 'who is the best player'
218
344
 
219
345
  usage = tracked_usage.first
@@ -426,10 +552,8 @@ You can use MCP clients as tools for any assistant:
426
552
  ```ruby
427
553
  mcp_client = Rasti::AI::MCP::Client.new url: 'https://mcp.server.ai/mcp'
428
554
 
429
-
430
- assistant = Rasti::AI::OpenAI::Assistant.new(
431
- mcp_servers: {my_mcp: mcp_client}
432
- )
555
+ assistant = Rasti::AI.create_assistant provider: :open_ai,
556
+ mcp_servers: {my_mcp: mcp_client}
433
557
 
434
558
  # The assistant can now call tools from the MCP server
435
559
  assistant.call 'What is 5 plus 3?'
@@ -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' => {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
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, content: prompt}
22
12
  end
@@ -61,7 +51,7 @@ module Rasti
61
51
  end
62
52
 
63
53
  def thinking_config
64
- THINKING_LEVELS[thinking]
54
+ Provider::THINKING_LEVELS[thinking]
65
55
  end
66
56
 
67
57
  def parse_tool_calls(response)
@@ -8,14 +8,14 @@ module Rasti
8
8
 
9
9
  def messages(messages:, model:nil, system:nil, tools:[], tool_choice:nil, max_tokens:nil, thinking:nil)
10
10
  body = {
11
- model: model || Rasti::AI.anthropic_default_model,
11
+ model: model || default_model,
12
12
  max_tokens: max_tokens || DEFAULT_MAX_TOKENS,
13
- messages: messages
13
+ messages: messages
14
14
  }
15
15
 
16
- body[:thinking] = thinking if thinking
17
- body[:system] = system if system
18
- body[:tools] = tools unless tools.empty?
16
+ body[:thinking] = thinking if thinking
17
+ body[:system] = system if system
18
+ body[:tools] = tools unless tools.empty?
19
19
  body[:tool_choice] = tool_choice if tool_choice
20
20
 
21
21
  post '/messages', body
@@ -23,31 +23,9 @@ module Rasti
23
23
 
24
24
  private
25
25
 
26
- def parse_usage(response)
27
- usage = response['usage']
28
- return unless usage
29
- Usage.new(
30
- provider: 'anthropic',
31
- model: response['model'],
32
- input_tokens: usage['input_tokens'],
33
- output_tokens: usage['output_tokens'],
34
- cached_tokens: usage['cache_read_input_tokens'] || 0,
35
- reasoning_tokens: 0,
36
- raw: usage
37
- )
38
- end
39
-
40
- def default_api_key
41
- Rasti::AI.anthropic_api_key
42
- end
43
-
44
- def base_url
45
- 'https://api.anthropic.com/v1'
46
- end
47
-
48
26
  def build_request(uri)
49
27
  request = super
50
- request['x-api-key'] = api_key
28
+ request['x-api-key'] = api_key
51
29
  request['anthropic-version'] = ANTHROPIC_VERSION
52
30
  request
53
31
  end
@@ -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