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.
- checksums.yaml +4 -4
- data/.features/api-normalization/api-design.md +184 -0
- data/.features/api-normalization/overview.md +230 -0
- data/AGENTS.md +125 -15
- data/README.md +143 -19
- data/lib/rasti/ai/anthropic/assistant.rb +1 -11
- data/lib/rasti/ai/anthropic/client.rb +6 -28
- data/lib/rasti/ai/anthropic/provider.rb +87 -0
- data/lib/rasti/ai/assistant.rb +10 -11
- data/lib/rasti/ai/client.rb +13 -10
- data/lib/rasti/ai/errors.rb +8 -0
- data/lib/rasti/ai/gemini/assistant.rb +1 -11
- data/lib/rasti/ai/gemini/client.rb +2 -24
- data/lib/rasti/ai/gemini/provider.rb +90 -0
- data/lib/rasti/ai/huawei_maas/assistant.rb +0 -7
- data/lib/rasti/ai/huawei_maas/client.rb +0 -19
- data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
- data/lib/rasti/ai/huawei_maas/roles.rb +1 -8
- data/lib/rasti/ai/open_ai/assistant.rb +0 -4
- data/lib/rasti/ai/open_ai/client.rb +3 -33
- data/lib/rasti/ai/open_ai/provider.rb +74 -0
- data/lib/rasti/ai/open_router/assistant.rb +0 -7
- data/lib/rasti/ai/open_router/client.rb +0 -19
- data/lib/rasti/ai/open_router/provider.rb +25 -0
- data/lib/rasti/ai/open_router/roles.rb +1 -8
- data/lib/rasti/ai/provider.rb +197 -0
- data/lib/rasti/ai/provider_aware.rb +17 -0
- data/lib/rasti/ai/result.rb +11 -0
- data/lib/rasti/ai/roles.rb +11 -0
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +93 -0
- data/spec/anthropic/provider_spec.rb +106 -0
- data/spec/gemini/provider_spec.rb +90 -0
- data/spec/open_ai/provider_spec.rb +101 -0
- data/spec/rasti_ai_spec.rb +322 -0
- data/spec/resources/open_ai/conversation_request.json +1 -0
- data/spec/resources/open_ai/system_request.json +1 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
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
|
|
316
|
+
http_read_timeout: 300
|
|
191
317
|
)
|
|
192
318
|
|
|
193
|
-
assistant = Rasti::AI
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
11
|
+
model: model || default_model,
|
|
12
12
|
max_tokens: max_tokens || DEFAULT_MAX_TOKENS,
|
|
13
|
-
messages:
|
|
13
|
+
messages: messages
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
body[:thinking]
|
|
17
|
-
body[:system]
|
|
18
|
-
body[:tools]
|
|
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']
|
|
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
|
data/lib/rasti/ai/assistant.rb
CHANGED
|
@@ -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
|
-
@
|
|
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 :
|
|
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
|
data/lib/rasti/ai/client.rb
CHANGED
|
@@ -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
|
|
21
|
-
|
|
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
|
|
27
|
-
|
|
27
|
+
def default_model
|
|
28
|
+
provider.default_model
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
def
|
|
31
|
-
|
|
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
|
-
|
|
38
|
+
provider.base_url
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def build_url(relative_url)
|
data/lib/rasti/ai/errors.rb
CHANGED
|
@@ -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 ||
|
|
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
|
-
"#{
|
|
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
|