rasti-ai 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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 +167 -10
  5. data/README.md +161 -23
  6. data/lib/rasti/ai/anthropic/assistant.rb +1 -11
  7. data/lib/rasti/ai/anthropic/client.rb +6 -28
  8. data/lib/rasti/ai/anthropic/provider.rb +87 -0
  9. data/lib/rasti/ai/assistant.rb +10 -11
  10. data/lib/rasti/ai/client.rb +13 -10
  11. data/lib/rasti/ai/errors.rb +8 -0
  12. data/lib/rasti/ai/gemini/assistant.rb +1 -11
  13. data/lib/rasti/ai/gemini/client.rb +2 -24
  14. data/lib/rasti/ai/gemini/provider.rb +90 -0
  15. data/lib/rasti/ai/huawei_maas/assistant.rb +8 -0
  16. data/lib/rasti/ai/huawei_maas/client.rb +8 -0
  17. data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
  18. data/lib/rasti/ai/huawei_maas/roles.rb +7 -0
  19. data/lib/rasti/ai/open_ai/assistant.rb +0 -4
  20. data/lib/rasti/ai/open_ai/client.rb +4 -26
  21. data/lib/rasti/ai/open_ai/provider.rb +74 -0
  22. data/lib/rasti/ai/open_router/assistant.rb +8 -0
  23. data/lib/rasti/ai/open_router/client.rb +8 -0
  24. data/lib/rasti/ai/open_router/provider.rb +25 -0
  25. data/lib/rasti/ai/open_router/roles.rb +7 -0
  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 +101 -1
  32. data/spec/anthropic/provider_spec.rb +106 -0
  33. data/spec/gemini/provider_spec.rb +90 -0
  34. data/spec/huawei_maas/assistant_spec.rb +80 -0
  35. data/spec/huawei_maas/client_spec.rb +60 -0
  36. data/spec/minitest_helper.rb +6 -0
  37. data/spec/open_ai/provider_spec.rb +101 -0
  38. data/spec/open_router/assistant_spec.rb +80 -0
  39. data/spec/open_router/client_spec.rb +60 -0
  40. data/spec/rasti_ai_spec.rb +322 -0
  41. data/spec/resources/open_ai/conversation_request.json +1 -0
  42. data/spec/resources/open_ai/system_request.json +1 -0
  43. data/tasks/assistant.rake +5 -3
  44. metadata +39 -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)
20
92
  ```
21
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
101
+ ```
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,14 +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
174
+ open_router/
175
+ roles.rb
176
+ client.rb
177
+ assistant.rb
178
+ provider.rb
179
+ huawei_maas/
180
+ roles.rb
181
+ client.rb
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)
79
188
  mcp/
80
189
  server.rb # Rack middleware exposing tools via JSON-RPC 2.0
81
190
  tools_registry.rb # per-request tool registry used by the middleware
@@ -100,6 +209,12 @@ spec/
100
209
  anthropic/
101
210
  client_spec.rb
102
211
  assistant_spec.rb
212
+ open_router/
213
+ client_spec.rb
214
+ assistant_spec.rb
215
+ huawei_maas/
216
+ client_spec.rb
217
+ assistant_spec.rb
103
218
  mcp/
104
219
  client_spec.rb
105
220
  server_spec.rb
@@ -125,6 +240,7 @@ spec/
125
240
  | | OpenAI | Gemini | Anthropic |
126
241
  |---|---|---|---|
127
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` |
128
244
  | Endpoint | `POST /chat/completions` | `POST /models/{model}:generateContent` | `POST /messages` |
129
245
  | System prompt | message with `role: system` | top-level `system_instruction` | top-level `system` string |
130
246
  | `max_tokens` | optional | optional | **required** (default 4096 in client) |
@@ -140,6 +256,19 @@ spec/
140
256
  - Gemini: renames to `parameters`
141
257
  - Anthropic: renames to `input_schema`
142
258
 
259
+ ### OpenAI-compatible providers
260
+
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.
262
+
263
+ | | OpenRouter | Huawei MaaS |
264
+ |---|---|---|
265
+ | Base URL | `https://openrouter.ai/api/v1` | `https://api-ap-southeast-1.modelarts-maas.com/v2` |
266
+ | Auth | `Authorization: Bearer {key}` | `Authorization: Bearer {key}` |
267
+ | Config | `openrouter_api_key` / `openrouter_default_model` | `huawei_maas_api_key` / `huawei_maas_default_model` |
268
+ | `provider` in Usage | `'open_router'` | `'huawei_maas'` |
269
+
270
+ Thinking is inherited too: `reasoning_effort` is passed through as-is (same as OpenAI).
271
+
143
272
 
144
273
  ## Thinking levels
145
274
 
@@ -151,18 +280,23 @@ The base `Assistant` accepts `thinking: 'low' | 'medium' | 'high'` (validated on
151
280
  | `'medium'` | `'medium'` | `8_000` | `8_192` |
152
281
  | `'high'` | `'high'` | `16_000` | `24_576` |
153
282
 
283
+ 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.
284
+
154
285
  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).
155
286
 
287
+ 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
+
156
289
  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).
157
290
 
158
291
 
159
292
  ## Adding a new provider
160
293
 
161
- Create three files under `lib/rasti/ai/<provider>/`:
294
+ Create four files under `lib/rasti/ai/<provider>/`:
162
295
 
163
296
  1. **`roles.rb`** — string constants for role names
164
- 2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method + private helpers
165
- 3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 12 template methods
297
+ 2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method + auth
298
+ 3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 11 template methods
299
+ 4. **`provider.rb`** — inherits `Rasti::AI::Provider`, implements the 7 methods + `THINKING_LEVELS`
166
300
 
167
301
  Add to `lib/rasti/ai.rb`:
168
302
  ```ruby
@@ -170,6 +304,10 @@ attr_config :<provider>_api_key, ENV['<PROVIDER>_API_KEY']
170
304
  attr_config :<provider>_default_model, ENV['<PROVIDER>_DEFAULT_MODEL']
171
305
  ```
172
306
 
307
+ 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).
308
+
309
+ The client must expose a private `default_model` returning the configured default (all providers do) and a **public** `parse_usage`.
310
+
173
311
  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`.
174
312
 
175
313
  Add an entry to the `PROVIDERS` table in `tasks/assistant.rake` so the interactive task is also available for the new provider:
@@ -189,6 +327,18 @@ config.<provider>_api_key = 'test_<provider>_api_key'
189
327
  config.<provider>_default_model = '<provider>-test'
190
328
  ```
191
329
 
330
+ ### OpenAI-compatible providers
331
+
332
+ 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:
333
+
334
+ - **client** — empty body, just the subclass declaration
335
+ - **assistant** — empty body, just the subclass declaration
336
+ - **provider** — overrides `name`, `default_model`, `default_api_key` and `base_url`; inherits `request`, `encode_message`, `parse_content`, `parse_usage` and `THINKING_LEVELS`
337
+
338
+ `Usage#provider` comes from `Provider#name`, so nothing else needs to know the provider's identity.
339
+
340
+ See `open_router/` and `huawei_maas/` for reference implementations.
341
+
192
342
  ### ⚠️ multi_require load order
193
343
 
194
344
  `require_relative_pattern 'ai/**/*'` loads files alphabetically. If the new provider name sorts before `assistant` or `client` (e.g. `anthropic` < `assistant`), the subclass is loaded before the base class and raises `NameError`.
@@ -203,9 +353,14 @@ require_relative 'ai/tool'
203
353
  require_relative 'ai/tool_serializer'
204
354
  require_relative 'ai/client'
205
355
  require_relative 'ai/assistant'
356
+ require_relative 'ai/open_ai/roles'
357
+ require_relative 'ai/open_ai/client'
358
+ require_relative 'ai/open_ai/assistant'
206
359
  require_relative_pattern 'ai/**/*' # duplicates are skipped by Ruby's require
207
360
  ```
208
361
 
362
+ The OpenAI provider is also explicitly required because OpenAI-compatible providers (OpenRouter, Huawei MaaS) inherit from it, and some of their directory names sort before `open_ai` alphabetically (e.g. `huawei_maas` < `open_ai`).
363
+
209
364
  If you add a provider whose name sorts before `client` alphabetically, the same mechanism protects it.
210
365
 
211
366
 
@@ -519,6 +674,8 @@ It's a simple class (not a `Tool` subclass) that returns a plain string — cove
519
674
  rake assistant:openai # OPENAI_API_KEY
520
675
  rake assistant:gemini # GEMINI_API_KEY
521
676
  rake assistant:anthropic # ANTHROPIC_API_KEY
677
+ rake assistant:openrouter # OPENROUTER_API_KEY
678
+ rake assistant:huawei_maas # HUAWEI_MAAS_API_KEY
522
679
  ```
523
680
  Each task validates the key, writes logs to `log/<provider>.log`, connects to the [Pipeworx](https://pipeworx.io) public weather MCP server, and starts a `You:` / `Assistant:` prompt loop (`exit` or `Ctrl+C` to quit). The model can be overridden with the matching env variable (e.g. `OPENAI_DEFAULT_MODEL=gpt-4o`).
524
681
 
data/README.md CHANGED
@@ -45,6 +45,17 @@ Rasti::AI.configure do |config|
45
45
  config.anthropic_api_key = 'sk-ant-12345' # Default ENV['ANTHROPIC_API_KEY']
46
46
  config.anthropic_default_model = 'claude-opus-4-5' # Default ENV['ANTHROPIC_DEFAULT_MODEL']
47
47
 
48
+ # OpenRouter
49
+ config.openrouter_api_key = 'sk-or-12345' # Default ENV['OPENROUTER_API_KEY']
50
+ config.openrouter_default_model = 'openai/gpt-4o-mini' # Default ENV['OPENROUTER_DEFAULT_MODEL']
51
+
52
+ # Huawei MaaS
53
+ config.huawei_maas_api_key = 'sk-maas-12345' # Default ENV['HUAWEI_MAAS_API_KEY']
54
+ config.huawei_maas_default_model = 'DeepSeek-V3' # Default ENV['HUAWEI_MAAS_DEFAULT_MODEL']
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
+
48
59
  # Usage tracking
49
60
  config.usage_tracker = ->(usage) { puts "#{usage.provider}: #{usage.input_tokens} in / #{usage.output_tokens} out" }
50
61
  end
@@ -55,13 +66,139 @@ end
55
66
  - **OpenAI** - `Rasti::AI::OpenAI::Assistant`
56
67
  - **Gemini** - `Rasti::AI::Gemini::Assistant`
57
68
  - **Anthropic** - `Rasti::AI::Anthropic::Assistant`
69
+ - **OpenRouter** - `Rasti::AI::OpenRouter::Assistant`
70
+ - **Huawei MaaS** - `Rasti::AI::HuaweiMaaS::Assistant`
71
+
72
+ All providers share the same interface. The examples below use OpenAI, but apply equally to any provider by replacing `OpenAI` with the provider name.
73
+
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.
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.
58
79
 
59
- All providers share the same interface. The examples below use OpenAI, but apply equally to Gemini or Anthropic by replacing `OpenAI` with the provider name.
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
+ ```
60
184
 
61
185
  ### Assistant
62
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
+
189
+ ```ruby
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
+
63
199
  ```ruby
64
- assistant = Rasti::AI::OpenAI::Assistant.new
200
+ assistant = Rasti::AI::OpenAI::Assistant.new system: 'Act as sports journalist',
201
+ tools: [GetCurrentWeather.new]
65
202
  assistant.call 'who is the best player' # => 'The best player is Lionel Messi'
66
203
  ```
67
204
 
@@ -126,7 +263,7 @@ tools = [
126
263
  GetCurrentWeather.new
127
264
  ]
128
265
 
129
- assistant = Rasti::AI::OpenAI::Assistant.new tools: tools
266
+ assistant = Rasti::AI.create_assistant provider: :open_ai, tools: tools
130
267
 
131
268
  assistant.call 'what time is it' # => 'The current time is 3:03 PM on April 28, 2025.'
132
269
 
@@ -137,7 +274,7 @@ assistant.call 'what is the weather in Buenos Aires' # => 'In Buenos Aires it is
137
274
  ```ruby
138
275
  state = Rasti::AI::AssistantState.new context: 'Act as sports journalist'
139
276
 
140
- assistant = Rasti::AI::OpenAI::Assistant.new state: state
277
+ assistant = Rasti::AI.create_assistant provider: :open_ai, state: state
141
278
 
142
279
  assistant.call 'who is the best player'
143
280
 
@@ -145,14 +282,15 @@ state.context # => 'Act as sports journalist'
145
282
  state.messages # Array of provider-specific message hashes
146
283
  ```
147
284
 
148
- 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: ...)`.
149
286
 
150
287
  ### Structured responses (JSON Schema)
151
288
  ```ruby
152
- assistant = Rasti::AI::OpenAI::Assistant.new json_schema: {
153
- player: 'string',
154
- sport: 'string'
155
- }
289
+ assistant = Rasti::AI.create_assistant provider: :open_ai,
290
+ json_schema: {
291
+ player: 'string',
292
+ sport: 'string'
293
+ }
156
294
 
157
295
  response = assistant.call 'who is the best player'
158
296
  JSON.parse response # => {"player" => "Lionel Messi", "sport" => "Football"}
@@ -161,7 +299,7 @@ JSON.parse response # => {"player" => "Lionel Messi", "sport" => "Football"}
161
299
  ### Custom model and client
162
300
  ```ruby
163
301
  # Override model
164
- assistant = Rasti::AI::OpenAI::Assistant.new model: 'gpt-4o'
302
+ assistant = Rasti::AI.create_assistant provider: :open_ai, model: 'gpt-4o'
165
303
 
166
304
  # Custom client with per-client HTTP settings
167
305
  client = Rasti::AI::OpenAI::Client.new(
@@ -170,15 +308,15 @@ client = Rasti::AI::OpenAI::Client.new(
170
308
  http_max_retries: 5
171
309
  )
172
310
 
173
- assistant = Rasti::AI::OpenAI::Assistant.new client: client
311
+ assistant = Rasti::AI.create_assistant provider: :open_ai, client: client
174
312
 
175
- # Anthropic client
313
+ # Anthropic client — Claude can be slow on long responses
176
314
  client = Rasti::AI::Anthropic::Client.new(
177
315
  http_connect_timeout: 120,
178
- http_read_timeout: 300 # Claude can be slow on long responses
316
+ http_read_timeout: 300
179
317
  )
180
318
 
181
- assistant = Rasti::AI::Anthropic::Assistant.new client: client
319
+ assistant = Rasti::AI.create_assistant provider: :anthropic, client: client
182
320
  ```
183
321
 
184
322
  ### Thinking / extended reasoning
@@ -186,7 +324,7 @@ assistant = Rasti::AI::Anthropic::Assistant.new client: client
186
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:
187
325
 
188
326
  ```ruby
189
- assistant = Rasti::AI::Anthropic::Assistant.new thinking: 'high'
327
+ assistant = Rasti::AI.create_assistant provider: :anthropic, thinking: 'high'
190
328
  assistant.call 'Solve this step by step: ...'
191
329
  ```
192
330
 
@@ -201,7 +339,7 @@ tracked_usage = []
201
339
  tracker = ->(usage) { tracked_usage << usage }
202
340
 
203
341
  client = Rasti::AI::OpenAI::Client.new usage_tracker: tracker
204
- assistant = Rasti::AI::OpenAI::Assistant.new client: client
342
+ assistant = Rasti::AI.create_assistant provider: :open_ai, client: client
205
343
  assistant.call 'who is the best player'
206
344
 
207
345
  usage = tracked_usage.first
@@ -414,10 +552,8 @@ You can use MCP clients as tools for any assistant:
414
552
  ```ruby
415
553
  mcp_client = Rasti::AI::MCP::Client.new url: 'https://mcp.server.ai/mcp'
416
554
 
417
-
418
- assistant = Rasti::AI::OpenAI::Assistant.new(
419
- mcp_servers: {my_mcp: mcp_client}
420
- )
555
+ assistant = Rasti::AI.create_assistant provider: :open_ai,
556
+ mcp_servers: {my_mcp: mcp_client}
421
557
 
422
558
  # The assistant can now call tools from the MCP server
423
559
  assistant.call 'What is 5 plus 3?'
@@ -428,9 +564,11 @@ assistant.call 'What is 5 plus 3?'
428
564
  The gem includes interactive chat tasks wired to the [Pipeworx](https://pipeworx.io) public weather MCP server (no auth required):
429
565
 
430
566
  ```bash
431
- OPENAI_API_KEY=sk-... rake assistant:openai
432
- GEMINI_API_KEY=AIza... rake assistant:gemini
433
- ANTHROPIC_API_KEY=sk-... rake assistant:anthropic
567
+ OPENAI_API_KEY=sk-... rake assistant:openai
568
+ GEMINI_API_KEY=AIza... rake assistant:gemini
569
+ ANTHROPIC_API_KEY=sk-... rake assistant:anthropic
570
+ OPENROUTER_API_KEY=sk-or... rake assistant:openrouter
571
+ HUAWEI_MAAS_API_KEY=sk-... rake assistant:huawei_maas
434
572
  ```
435
573
 
436
574
  Type your message and press Enter. Type `exit` or `Ctrl+C` to quit.
@@ -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