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.
- checksums.yaml +4 -4
- data/.features/api-normalization/api-design.md +184 -0
- data/.features/api-normalization/overview.md +230 -0
- data/AGENTS.md +167 -10
- data/README.md +161 -23
- data/lib/rasti/ai/anthropic/assistant.rb +1 -11
- data/lib/rasti/ai/anthropic/client.rb +6 -28
- data/lib/rasti/ai/anthropic/provider.rb +87 -0
- data/lib/rasti/ai/assistant.rb +10 -11
- data/lib/rasti/ai/client.rb +13 -10
- data/lib/rasti/ai/errors.rb +8 -0
- data/lib/rasti/ai/gemini/assistant.rb +1 -11
- data/lib/rasti/ai/gemini/client.rb +2 -24
- data/lib/rasti/ai/gemini/provider.rb +90 -0
- data/lib/rasti/ai/huawei_maas/assistant.rb +8 -0
- data/lib/rasti/ai/huawei_maas/client.rb +8 -0
- data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
- data/lib/rasti/ai/huawei_maas/roles.rb +7 -0
- data/lib/rasti/ai/open_ai/assistant.rb +0 -4
- data/lib/rasti/ai/open_ai/client.rb +4 -26
- data/lib/rasti/ai/open_ai/provider.rb +74 -0
- data/lib/rasti/ai/open_router/assistant.rb +8 -0
- data/lib/rasti/ai/open_router/client.rb +8 -0
- data/lib/rasti/ai/open_router/provider.rb +25 -0
- data/lib/rasti/ai/open_router/roles.rb +7 -0
- 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 +101 -1
- data/spec/anthropic/provider_spec.rb +106 -0
- data/spec/gemini/provider_spec.rb +90 -0
- data/spec/huawei_maas/assistant_spec.rb +80 -0
- data/spec/huawei_maas/client_spec.rb +60 -0
- data/spec/minitest_helper.rb +6 -0
- data/spec/open_ai/provider_spec.rb +101 -0
- data/spec/open_router/assistant_spec.rb +80 -0
- data/spec/open_router/client_spec.rb +60 -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 +5 -3
- 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
|
|
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 (
|
|
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
|
|
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 +
|
|
165
|
-
3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
|
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
|
|
316
|
+
http_read_timeout: 300
|
|
179
317
|
)
|
|
180
318
|
|
|
181
|
-
assistant = Rasti::AI
|
|
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
|
|
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
|
|
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
|
-
|
|
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-...
|
|
432
|
-
GEMINI_API_KEY=AIza...
|
|
433
|
-
ANTHROPIC_API_KEY=sk-...
|
|
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:
|
|
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
|