rasti-ai 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.features/api-normalization/api-design.md +184 -0
  3. data/.features/api-normalization/overview.md +230 -0
  4. data/AGENTS.md +136 -19
  5. data/README.md +151 -19
  6. data/lib/rasti/ai/anthropic/assistant.rb +1 -11
  7. data/lib/rasti/ai/anthropic/client.rb +6 -28
  8. data/lib/rasti/ai/anthropic/provider.rb +88 -0
  9. data/lib/rasti/ai/assistant.rb +11 -12
  10. data/lib/rasti/ai/client.rb +13 -10
  11. data/lib/rasti/ai/errors.rb +8 -0
  12. data/lib/rasti/ai/gemini/assistant.rb +1 -11
  13. data/lib/rasti/ai/gemini/client.rb +2 -24
  14. data/lib/rasti/ai/gemini/provider.rb +91 -0
  15. data/lib/rasti/ai/huawei_maas/assistant.rb +0 -7
  16. data/lib/rasti/ai/huawei_maas/client.rb +0 -19
  17. data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
  18. data/lib/rasti/ai/huawei_maas/roles.rb +1 -8
  19. data/lib/rasti/ai/open_ai/assistant.rb +1 -5
  20. data/lib/rasti/ai/open_ai/client.rb +3 -33
  21. data/lib/rasti/ai/open_ai/provider.rb +75 -0
  22. data/lib/rasti/ai/open_router/assistant.rb +0 -7
  23. data/lib/rasti/ai/open_router/client.rb +0 -19
  24. data/lib/rasti/ai/open_router/provider.rb +25 -0
  25. data/lib/rasti/ai/open_router/roles.rb +1 -8
  26. data/lib/rasti/ai/provider.rb +197 -0
  27. data/lib/rasti/ai/provider_aware.rb +17 -0
  28. data/lib/rasti/ai/result.rb +11 -0
  29. data/lib/rasti/ai/roles.rb +11 -0
  30. data/lib/rasti/ai/version.rb +1 -1
  31. data/lib/rasti/ai.rb +93 -0
  32. data/spec/anthropic/assistant_spec.rb +14 -0
  33. data/spec/anthropic/provider_spec.rb +121 -0
  34. data/spec/gemini/assistant_spec.rb +15 -0
  35. data/spec/gemini/provider_spec.rb +104 -0
  36. data/spec/open_ai/assistant_spec.rb +15 -0
  37. data/spec/open_ai/provider_spec.rb +111 -0
  38. data/spec/rasti_ai_spec.rb +322 -0
  39. data/spec/resources/open_ai/conversation_request.json +1 -0
  40. data/spec/resources/open_ai/system_request.json +1 -0
  41. data/tasks/assistant.rake +8 -8
  42. metadata +25 -2
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 unless URL needs query params (e.g. Gemini key)
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 (12 total)
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. Their clients and assistants inherit from the OpenAI provider and override only `base_url`, `default_api_key`, `default_model`, and `provider_name` (client) / `build_default_client` (assistant). No template methods are reimplemented.
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; nil = disabled). 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.
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. Note that Huawei's models typically accept only `'high'` and `'max'` — `'high'` is the safe choice, and `'max'` is not expressible through the gem's universal levels.
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 three files under `lib/rasti/ai/<provider>/`:
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 + private helpers
194
- 3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 12 template methods
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::Assistant` instead of the abstract bases. The assistant only overrides `build_default_client`; the client overrides `base_url`, `default_api_key`, `default_model`, and `provider_name`. No template methods are reimplemented.
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
- The OpenAI client exposes two private hooks that make this possible without duplicating `chat_completions` or `parse_usage`:
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
- - **`default_model`** — returns the provider's default model from `Rasti::AI` config
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::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,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::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
 
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::OpenAI::Assistant.new client: client
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
- assistant = Rasti::AI::OpenAI::Assistant.new(
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: 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