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.
- checksums.yaml +4 -4
- data/.features/api-normalization/api-design.md +184 -0
- data/.features/api-normalization/overview.md +230 -0
- data/AGENTS.md +136 -19
- data/README.md +151 -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 +88 -0
- data/lib/rasti/ai/assistant.rb +11 -12
- 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 +91 -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 +1 -5
- data/lib/rasti/ai/open_ai/client.rb +3 -33
- data/lib/rasti/ai/open_ai/provider.rb +75 -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/assistant_spec.rb +14 -0
- data/spec/anthropic/provider_spec.rb +121 -0
- data/spec/gemini/assistant_spec.rb +15 -0
- data/spec/gemini/provider_spec.rb +104 -0
- data/spec/open_ai/assistant_spec.rb +15 -0
- data/spec/open_ai/provider_spec.rb +111 -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
- data/tasks/assistant.rake +8 -8
- metadata +25 -2
data/AGENTS.md
CHANGED
|
@@ -4,27 +4,115 @@ Developer and agent reference. For usage examples see README.md.
|
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
|
+
### Provider
|
|
8
|
+
|
|
9
|
+
`Rasti::AI::Provider` is the entry point that makes provider and model a runtime value instead of a class reference. An instance holds the transport configuration (`api_key`, `usage_tracker`, `logger`, HTTP timeouts) plus the default `model`, and knows how to build the provider's `Client` and `Assistant`.
|
|
10
|
+
|
|
11
|
+
The set of providers is **fixed** — there is no registry and third-party providers are not supported. Two frozen constants in the base class drive resolution:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
MODULES = {open_ai: 'OpenAI', gemini: 'Gemini', anthropic: 'Anthropic', open_router: 'OpenRouter', huawei_maas: 'HuaweiMaaS'}.freeze
|
|
15
|
+
ALIASES = {openai: :open_ai, openrouter: :open_router, huawei: :huawei_maas}.freeze
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`Provider.build(name, **options)` normalizes the name (`downcase.to_sym`), applies aliases, and resolves the class with `AI.const_get(MODULES[key])::Provider` — at call time, so there is no load-order constraint. Passing an existing `Provider` instance returns it untouched. Unknown names raise `Errors::UnknownProvider`.
|
|
19
|
+
|
|
20
|
+
`client_class` / `assistant_class` are derived from `name` through `provider_module`, so `OpenRouter::Provider` (which inherits from `OpenAI::Provider`) gets `OpenRouter::Client` and `OpenRouter::Assistant` without overriding anything.
|
|
21
|
+
|
|
22
|
+
#### Provider — methods to implement (7 total)
|
|
23
|
+
|
|
24
|
+
Public — also consumed by the `Client`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
def name # :open_ai — also used by provider_module
|
|
28
|
+
def default_model # from Rasti::AI config
|
|
29
|
+
def default_api_key # from Rasti::AI config
|
|
30
|
+
def base_url # e.g. 'https://api.anthropic.com/v1'
|
|
31
|
+
def parse_usage(response) # raw response => Usage or nil
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Private — the request/response translation:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
def request(client:, messages:, system:, model:, json_schema:, thinking:) # calls the client, returns raw response
|
|
38
|
+
def encode_message(message) # {role:, content:} with generic roles => provider message
|
|
39
|
+
def parse_content(response) # raw response => String
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Plus a `THINKING_LEVELS` constant (see [Thinking levels](#thinking-levels)). OpenAI-compatible providers only override `name`, `default_model` and `default_api_key`.
|
|
43
|
+
|
|
44
|
+
#### Public API
|
|
45
|
+
|
|
46
|
+
- **`Provider#generate_text(prompt:/messages:, system:, model:, json_schema:, thinking:, client:)`** — one request, no tools, no loop. Normalizes generic-role messages, extracts `system` messages into the provider's system field, and returns a `Result`.
|
|
47
|
+
- **`Provider#create_assistant(state:, system:, model:, tools:, mcp_servers:, json_schema:, thinking:, client:)`** — builds the provider-specific `Assistant`. `system:` is sugar for `state: AssistantState.new(context: ...)`; passing both raises `ArgumentError`.
|
|
48
|
+
- **`Provider#build_client`** — the provider's `Client` with the instance's transport options.
|
|
49
|
+
- **`Rasti::AI.provider`, `Rasti::AI.generate_text`, `Rasti::AI.create_assistant`** — thin module-level wrappers. They split transport options (used to build the provider) from call options (forwarded to the provider method).
|
|
50
|
+
|
|
51
|
+
`model:` accepts `'provider:model'` (split on the first `:`), but only when `provider:` is not given explicitly. The provider falls back to `Rasti::AI.default_provider` (`ENV['AI_DEFAULT_PROVIDER']`); nil raises `ArgumentError`. `model` on a `Provider` is only its default — every call accepts a `model:` override.
|
|
52
|
+
|
|
53
|
+
The private helpers `build_provider` and `split_provider_and_model` live in a `class << self` block with `private` inside it.
|
|
54
|
+
|
|
55
|
+
### Result
|
|
56
|
+
|
|
57
|
+
`Rasti::AI::Result` (a `Rasti::Model`) is the normalized output of `generate_text`:
|
|
58
|
+
|
|
59
|
+
| Attribute | Content |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `content` | The text the model returned, or the JSON string when `json_schema` is used |
|
|
62
|
+
| `usage` | A `Usage` instance, from `Provider#parse_usage` |
|
|
63
|
+
| `raw` | The untouched provider payload |
|
|
64
|
+
|
|
65
|
+
`Provider#parse_usage` is public because the `Client` also calls it, from `track_usage`, on every response of the assistant's tool loop.
|
|
66
|
+
|
|
67
|
+
`Assistant#call` still returns a plain `String` — unchanged for compatibility.
|
|
68
|
+
|
|
69
|
+
### Generic roles
|
|
70
|
+
|
|
71
|
+
`Rasti::AI::Roles` (`USER`, `ASSISTANT`, `SYSTEM`) is the provider-agnostic vocabulary used by `generate_text(messages:)`. Each `Provider#encode_message` translates it (Gemini maps `assistant` to its `model` role; `system` messages are pulled out of the array by `split_system` and passed through the provider's system field).
|
|
72
|
+
|
|
73
|
+
The per-provider `Roles` modules still exist and are what the `Assistant` classes use. Inside `Rasti::AI::<Provider>::*`, a bare `Roles::USER` resolves to the provider's module by lexical scope — use the fully qualified `Rasti::AI::Roles::USER` for the generic one.
|
|
74
|
+
|
|
75
|
+
> ⚠️ Do not reference a provider's `Roles` constants in a constant definition inside `<provider>/provider.rb`: `multi_require` loads `provider.rb` **before** `roles.rb` (alphabetical order). Resolve them inside method bodies instead (see `Gemini::Provider#encode_role`).
|
|
76
|
+
|
|
77
|
+
#### Known duplication
|
|
78
|
+
|
|
79
|
+
`Anthropic::Provider` reimplements the structured-output tool and its parsing, which also live in `Anthropic::Assistant` (Anthropic has no native `json_schema` support). Same for Gemini's `generation_config`. This is temporary: once the `Assistant` is rebuilt on top of `Provider#request`, both collapse into one. Keep them in sync until then.
|
|
80
|
+
|
|
7
81
|
### Template method pattern
|
|
8
82
|
|
|
9
83
|
`Rasti::AI::Client` and `Rasti::AI::Assistant` are abstract base classes. Provider-specific subclasses implement a fixed set of methods; all shared logic (HTTP retries, tool caching, the request/response loop) lives in the base.
|
|
10
84
|
|
|
11
85
|
#### Client — methods to implement
|
|
12
86
|
|
|
87
|
+
Nothing is mandatory. A client only defines the provider's endpoint method (`chat_completions`, `messages`, `generate_content`) and, if the API needs credentials in the request, one of:
|
|
88
|
+
|
|
13
89
|
```ruby
|
|
14
|
-
def default_api_key # reads from Rasti::AI config
|
|
15
|
-
def base_url # e.g. 'https://api.anthropic.com/v1'
|
|
16
|
-
def parse_usage(response) # returns a Usage instance or nil
|
|
17
|
-
# optionally override:
|
|
18
90
|
def build_request(uri) # super + add auth headers
|
|
19
|
-
def build_url(relative_url) # super
|
|
91
|
+
def build_url(relative_url) # super + query params (e.g. Gemini key)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Everything else — API key, default model, base URL, usage parsing — lives in the `Provider`, and the client delegates (`provider` and `provider_module` come from the `ProviderAware` module, shared with `Assistant`):
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
def default_api_key ; provider.default_api_key ; end
|
|
98
|
+
def default_model ; provider.default_model ; end
|
|
99
|
+
def base_url ; provider.base_url ; end
|
|
100
|
+
def track_usage(response) ; ... provider.parse_usage(response) ... ; end
|
|
20
101
|
```
|
|
21
102
|
|
|
103
|
+
As a result `OpenRouter::Client` and `HuaweiMaaS::Client` have **empty bodies**. Keep them: they are the namespace marker that `Provider#client_class` and `ProviderAware#provider_module` resolve against, and they keep `Rasti::AI::<Provider>::Client.new` working as a documented entry point.
|
|
104
|
+
|
|
105
|
+
`Provider#build_client` injects itself (`provider: self`). When a client is built standalone (`OpenAI::Client.new`) the private `provider` method lazily builds the provider of its own namespace, derived from the class name — so nothing breaks and no provider-specific config is duplicated.
|
|
106
|
+
|
|
107
|
+
> ⚠️ In `Client#initialize`, the `provider:` keyword argument shadows the private `provider` method. That's why the fallback goes through `default_api_key` instead of calling `provider.default_api_key` inline.
|
|
108
|
+
|
|
109
|
+
> ⚠️ The class-name derivation means an anonymous client subclass (`Class.new(OpenAI::Client)`) has no provider. Subclass explicitly if you need one in a test.
|
|
110
|
+
|
|
22
111
|
The base `post` method handles JSON serialization, logging, retries on network errors and 5xx, and calls `track_usage` after each successful response.
|
|
23
112
|
|
|
24
|
-
#### Assistant — methods to implement (
|
|
113
|
+
#### Assistant — methods to implement (11 total)
|
|
25
114
|
|
|
26
115
|
```ruby
|
|
27
|
-
def build_default_client # Client.new
|
|
28
116
|
def build_user_message(prompt) # {role: ..., content: prompt}
|
|
29
117
|
def build_assistant_message(content)
|
|
30
118
|
def build_assistant_tool_calls_message(response)
|
|
@@ -40,6 +128,10 @@ def extract_tool_name(wrapped) # string name from wrapped tool hash
|
|
|
40
128
|
|
|
41
129
|
The base `call` loop: add user message → request completion → if tool calls: execute each, add results, loop → else: return content.
|
|
42
130
|
|
|
131
|
+
The client is **not** a template method: the base `Assistant` gets it from its provider (`provider.build_client`), lazily, so nothing is built until the first request. As with `Client`, `Provider#create_assistant` injects `provider: self`, and direct instantiation (`Rasti::AI::OpenRouter::Assistant.new`) falls back to the namespace derivation.
|
|
132
|
+
|
|
133
|
+
That leaves `OpenRouter::Assistant` and `HuaweiMaaS::Assistant` with **empty bodies** — like their clients, they survive as the documented public entry point per provider and as the namespace marker `Provider#assistant_class` resolves.
|
|
134
|
+
|
|
43
135
|
#### Tool — optional base class
|
|
44
136
|
|
|
45
137
|
Simple tool classes only need a `.form` class method and a `#call(params={})` instance method.
|
|
@@ -68,22 +160,31 @@ lib/
|
|
|
68
160
|
roles.rb
|
|
69
161
|
client.rb
|
|
70
162
|
assistant.rb
|
|
163
|
+
provider.rb
|
|
71
164
|
gemini/
|
|
72
165
|
roles.rb
|
|
73
166
|
client.rb
|
|
74
167
|
assistant.rb
|
|
168
|
+
provider.rb
|
|
75
169
|
anthropic/
|
|
76
170
|
roles.rb
|
|
77
171
|
client.rb
|
|
78
172
|
assistant.rb
|
|
173
|
+
provider.rb
|
|
79
174
|
open_router/
|
|
80
175
|
roles.rb
|
|
81
176
|
client.rb
|
|
82
177
|
assistant.rb
|
|
178
|
+
provider.rb
|
|
83
179
|
huawei_maas/
|
|
84
180
|
roles.rb
|
|
85
181
|
client.rb
|
|
86
182
|
assistant.rb
|
|
183
|
+
provider.rb
|
|
184
|
+
provider.rb # entry point: resolves provider/model, builds client + assistant, generate_text
|
|
185
|
+
provider_aware.rb # shared lazy provider lookup by namespace (Client + Assistant)
|
|
186
|
+
result.rb # normalized generate_text output (content, usage, raw)
|
|
187
|
+
roles.rb # generic roles (user, assistant, system)
|
|
87
188
|
mcp/
|
|
88
189
|
server.rb # Rack middleware exposing tools via JSON-RPC 2.0
|
|
89
190
|
tools_registry.rb # per-request tool registry used by the middleware
|
|
@@ -139,6 +240,7 @@ spec/
|
|
|
139
240
|
| | OpenAI | Gemini | Anthropic |
|
|
140
241
|
|---|---|---|---|
|
|
141
242
|
| Auth | `Authorization: Bearer {key}` | `?key=` query param | `x-api-key: {key}` + `anthropic-version: 2023-06-01` header |
|
|
243
|
+
| Usage payload | `usage.prompt_tokens` / `completion_tokens` | `usageMetadata.promptTokenCount` / `candidatesTokenCount` | `usage.input_tokens` / `output_tokens` |
|
|
142
244
|
| Endpoint | `POST /chat/completions` | `POST /models/{model}:generateContent` | `POST /messages` |
|
|
143
245
|
| System prompt | message with `role: system` | top-level `system_instruction` | top-level `system` string |
|
|
144
246
|
| `max_tokens` | optional | optional | **required** (default 4096 in client) |
|
|
@@ -156,7 +258,7 @@ spec/
|
|
|
156
258
|
|
|
157
259
|
### OpenAI-compatible providers
|
|
158
260
|
|
|
159
|
-
OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth.
|
|
261
|
+
OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth. They inherit from the OpenAI provider; their whole implementation is `name` / `default_model` / `default_api_key` / `base_url` on the provider. Their clients and assistants are empty subclasses.
|
|
160
262
|
|
|
161
263
|
| | OpenRouter | Huawei MaaS |
|
|
162
264
|
|---|---|---|
|
|
@@ -170,28 +272,36 @@ Thinking is inherited too: `reasoning_effort` is passed through as-is (same as O
|
|
|
170
272
|
|
|
171
273
|
## Thinking levels
|
|
172
274
|
|
|
173
|
-
The base `Assistant` accepts `thinking: 'low' | 'medium' | 'high'` (validated on construction
|
|
275
|
+
The base `Assistant` accepts `thinking: 'none' | 'low' | 'medium' | 'high'` (validated on construction). `nil` means the gem sends nothing and the model uses its own default — which for reasoning models (e.g. `gpt-6-luna`, `gemini-2.5-flash`, Huawei's DeepSeek/GLM) means reasoning *on*. `'none'` explicitly asks the provider to disable reasoning. Each provider translates it in a private `thinking_config` method and passes the result to the client. The client includes it in the request body only if present.
|
|
174
276
|
|
|
175
277
|
| Level | OpenAI `reasoning_effort` | Anthropic `budget_tokens` | Gemini `thinking_budget` |
|
|
176
278
|
|---|---|---|---|
|
|
279
|
+
| `'none'` | `'none'` | _(field omitted)_ | `0` |
|
|
177
280
|
| `'low'` | `'low'` | `1_024` | `1_024` |
|
|
178
281
|
| `'medium'` | `'medium'` | `8_000` | `8_192` |
|
|
179
282
|
| `'high'` | `'high'` | `16_000` | `24_576` |
|
|
180
283
|
|
|
284
|
+
The `THINKING_LEVELS` table lives in each **`Provider`** class (it's provider metadata, not assistant behavior). `Assistant#thinking_config` reads it as `Provider::THINKING_LEVELS[thinking]` — resolved by lexical scope to the provider's own class. `Provider#thinking_config` also validates the level against the table's keys.
|
|
285
|
+
|
|
181
286
|
For Gemini, `thinking_config` goes inside `generation_config` — the client doesn't need a new param. For OpenAI and Anthropic, it's a separate top-level param in the client method (`reasoning_effort:` and `thinking:` respectively).
|
|
182
287
|
|
|
183
|
-
OpenRouter and Huawei MaaS inherit OpenAI's behavior: `thinking` is passed through as `reasoning_effort` with the same string values (`'low'`/`'medium'`/`'high'`). No `thinking_config` override is needed.
|
|
288
|
+
OpenRouter and Huawei MaaS inherit OpenAI's behavior: `thinking` is passed through as `reasoning_effort` with the same string values (`'low'`/`'medium'`/`'high'`). No `thinking_config` override is needed. Huawei MaaS rejects `reasoning_effort: 'none'` with a 400 (`deepseek-v4-flash` accepts `low|medium|high|xhigh|max`, `glm-5.3` only `low|high|max`), so `thinking: 'none'` fails there by design. Disabling reasoning on Huawei needs `thinking: {type: 'disabled'}` instead (works on DeepSeek and GLM ≤ 5.2, rejected by `glm-5.3`); the gem does not send it.
|
|
289
|
+
|
|
290
|
+
`'none'` matters for `gpt-6-luna`: it reasons by default and rejects function tools on `/v1/chat/completions` unless `reasoning_effort: 'none'` is sent. Combining reasoning with tools there requires `/v1/responses`, which the gem does not support.
|
|
291
|
+
|
|
292
|
+
Gemini's `thinking_budget: 0` is rejected by models that cannot turn reasoning off (e.g. `gemini-2.5-pro`).
|
|
184
293
|
|
|
185
294
|
The loop does not change. Anthropic thinking blocks (`type: 'thinking'`) in responses are ignored by `parse_content` (looks for `type == 'text'`) and preserved automatically by `build_assistant_tool_calls_message` (passes full `response['content']` array).
|
|
186
295
|
|
|
187
296
|
|
|
188
297
|
## Adding a new provider
|
|
189
298
|
|
|
190
|
-
Create
|
|
299
|
+
Create four files under `lib/rasti/ai/<provider>/`:
|
|
191
300
|
|
|
192
301
|
1. **`roles.rb`** — string constants for role names
|
|
193
|
-
2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method +
|
|
194
|
-
3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all
|
|
302
|
+
2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method + auth
|
|
303
|
+
3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 11 template methods
|
|
304
|
+
4. **`provider.rb`** — inherits `Rasti::AI::Provider`, implements the 7 methods + `THINKING_LEVELS`
|
|
195
305
|
|
|
196
306
|
Add to `lib/rasti/ai.rb`:
|
|
197
307
|
```ruby
|
|
@@ -199,6 +309,10 @@ attr_config :<provider>_api_key, ENV['<PROVIDER>_API_KEY']
|
|
|
199
309
|
attr_config :<provider>_default_model, ENV['<PROVIDER>_DEFAULT_MODEL']
|
|
200
310
|
```
|
|
201
311
|
|
|
312
|
+
Add the provider to `MODULES` (and `ALIASES` if the name has a common alternative spelling) in `lib/rasti/ai/provider.rb`, and create a fourth file `provider.rb` implementing the 6 methods listed in [Provider](#provider).
|
|
313
|
+
|
|
314
|
+
The client must expose a private `default_model` returning the configured default (all providers do) and a **public** `parse_usage`.
|
|
315
|
+
|
|
202
316
|
If the new provider supports thinking, define a `THINKING_LEVELS` constant and a private `thinking_config` method (see existing providers). The base constructor already validates and exposes `thinking`.
|
|
203
317
|
|
|
204
318
|
Add an entry to the `PROVIDERS` table in `tasks/assistant.rake` so the interactive task is also available for the new provider:
|
|
@@ -206,10 +320,12 @@ Add an entry to the `PROVIDERS` table in `tasks/assistant.rake` so the interacti
|
|
|
206
320
|
```ruby
|
|
207
321
|
PROVIDERS = {
|
|
208
322
|
# existing providers ...
|
|
209
|
-
'<provider>' => {key: '<PROVIDER>_API_KEY', klass: -> { Rasti::AI::<Provider>::Assistant }}
|
|
323
|
+
'<provider>' => {key: '<PROVIDER>_API_KEY', klass: -> { Rasti::AI::<Provider>::Assistant }, thinking: 'none'}
|
|
210
324
|
}.freeze
|
|
211
325
|
```
|
|
212
326
|
|
|
327
|
+
`thinking:` is optional and passed as-is to the assistant. All providers use `'none'` except `huawei_maas`, which rejects it (see [Thinking levels](#thinking-levels)).
|
|
328
|
+
|
|
213
329
|
The task name, description, env-key check, logger path and banner are all derived automatically from this entry.
|
|
214
330
|
|
|
215
331
|
Add to `spec/minitest_helper.rb`:
|
|
@@ -220,12 +336,13 @@ config.<provider>_default_model = '<provider>-test'
|
|
|
220
336
|
|
|
221
337
|
### OpenAI-compatible providers
|
|
222
338
|
|
|
223
|
-
If the new provider speaks the OpenAI chat completions protocol (same endpoint, request body, response shape, tool calling, Bearer auth), inherit from `Rasti::AI::OpenAI::Client` and `Rasti::AI::OpenAI::
|
|
339
|
+
If the new provider speaks the OpenAI chat completions protocol (same endpoint, request body, response shape, tool calling, Bearer auth), inherit from `Rasti::AI::OpenAI::Client`, `Rasti::AI::OpenAI::Assistant` and `Rasti::AI::OpenAI::Provider` instead of the abstract bases:
|
|
224
340
|
|
|
225
|
-
|
|
341
|
+
- **client** — empty body, just the subclass declaration
|
|
342
|
+
- **assistant** — empty body, just the subclass declaration
|
|
343
|
+
- **provider** — overrides `name`, `default_model`, `default_api_key` and `base_url`; inherits `request`, `encode_message`, `parse_content`, `parse_usage` and `THINKING_LEVELS`
|
|
226
344
|
|
|
227
|
-
|
|
228
|
-
- **`provider_name`** — the string written to `Usage#provider`
|
|
345
|
+
`Usage#provider` comes from `Provider#name`, so nothing else needs to know the provider's identity.
|
|
229
346
|
|
|
230
347
|
See `open_router/` and `huawei_maas/` for reference implementations.
|
|
231
348
|
|
|
@@ -282,7 +399,7 @@ All string constants use `.freeze`. Integer and array/hash literals that are alr
|
|
|
282
399
|
USER = 'user'.freeze
|
|
283
400
|
ASSISTANT = 'assistant'.freeze
|
|
284
401
|
|
|
285
|
-
VALID_THINKING_LEVELS = %w[low medium high].freeze
|
|
402
|
+
VALID_THINKING_LEVELS = %w[none low medium high].freeze
|
|
286
403
|
|
|
287
404
|
THINKING_LEVELS = {
|
|
288
405
|
'low' => {thinking_budget: 1_024}.freeze,
|
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:` | `'none'`, `'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,12 +324,20 @@ 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
|
|
|
205
331
|
The level controls how much computation the model can spend reasoning before responding. Higher levels may improve answer quality at the cost of more tokens and latency. Not all models support thinking — check your provider's documentation.
|
|
206
332
|
|
|
333
|
+
Omitting `thinking:` leaves the decision to the model, and many current models reason by default. Pass `thinking: 'none'` to disable reasoning explicitly. Some models require it — `gpt-6-luna`, for example, rejects function tools unless reasoning is disabled:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai, model: 'gpt-6-luna', tools: [GetCurrentWeather.new], thinking: 'none'
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Huawei MaaS does not accept `'none'` and returns an error.
|
|
340
|
+
|
|
207
341
|
### Usage tracking
|
|
208
342
|
|
|
209
343
|
Track token consumption across API calls (including tool calls):
|
|
@@ -213,7 +347,7 @@ tracked_usage = []
|
|
|
213
347
|
tracker = ->(usage) { tracked_usage << usage }
|
|
214
348
|
|
|
215
349
|
client = Rasti::AI::OpenAI::Client.new usage_tracker: tracker
|
|
216
|
-
assistant = Rasti::AI
|
|
350
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai, client: client
|
|
217
351
|
assistant.call 'who is the best player'
|
|
218
352
|
|
|
219
353
|
usage = tracked_usage.first
|
|
@@ -426,10 +560,8 @@ You can use MCP clients as tools for any assistant:
|
|
|
426
560
|
```ruby
|
|
427
561
|
mcp_client = Rasti::AI::MCP::Client.new url: 'https://mcp.server.ai/mcp'
|
|
428
562
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
mcp_servers: {my_mcp: mcp_client}
|
|
432
|
-
)
|
|
563
|
+
assistant = Rasti::AI.create_assistant provider: :open_ai,
|
|
564
|
+
mcp_servers: {my_mcp: mcp_client}
|
|
433
565
|
|
|
434
566
|
# The assistant can now call tools from the MCP server
|
|
435
567
|
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
|