rasti-ai 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.features/api-normalization/api-design.md +184 -0
- data/.features/api-normalization/overview.md +230 -0
- data/AGENTS.md +125 -15
- data/README.md +143 -19
- data/lib/rasti/ai/anthropic/assistant.rb +1 -11
- data/lib/rasti/ai/anthropic/client.rb +6 -28
- data/lib/rasti/ai/anthropic/provider.rb +87 -0
- data/lib/rasti/ai/assistant.rb +10 -11
- data/lib/rasti/ai/client.rb +13 -10
- data/lib/rasti/ai/errors.rb +8 -0
- data/lib/rasti/ai/gemini/assistant.rb +1 -11
- data/lib/rasti/ai/gemini/client.rb +2 -24
- data/lib/rasti/ai/gemini/provider.rb +90 -0
- data/lib/rasti/ai/huawei_maas/assistant.rb +0 -7
- data/lib/rasti/ai/huawei_maas/client.rb +0 -19
- data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
- data/lib/rasti/ai/huawei_maas/roles.rb +1 -8
- data/lib/rasti/ai/open_ai/assistant.rb +0 -4
- data/lib/rasti/ai/open_ai/client.rb +3 -33
- data/lib/rasti/ai/open_ai/provider.rb +74 -0
- data/lib/rasti/ai/open_router/assistant.rb +0 -7
- data/lib/rasti/ai/open_router/client.rb +0 -19
- data/lib/rasti/ai/open_router/provider.rb +25 -0
- data/lib/rasti/ai/open_router/roles.rb +1 -8
- data/lib/rasti/ai/provider.rb +197 -0
- data/lib/rasti/ai/provider_aware.rb +17 -0
- data/lib/rasti/ai/result.rb +11 -0
- data/lib/rasti/ai/roles.rb +11 -0
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +93 -0
- data/spec/anthropic/provider_spec.rb +106 -0
- data/spec/gemini/provider_spec.rb +90 -0
- data/spec/open_ai/provider_spec.rb +101 -0
- data/spec/rasti_ai_spec.rb +322 -0
- data/spec/resources/open_ai/conversation_request.json +1 -0
- data/spec/resources/open_ai/system_request.json +1 -0
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa07486ef7216a0a1408d1711ea21ca521283673c06ca2845ad63197dd1dbe8d
|
|
4
|
+
data.tar.gz: dd42552dfd7e3752dac0264b1c89159f04f230c6d067fa674dd4347aae01b0b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cd0846a2f725823ba691f7d761c5abee299291d9307a7a870cc086f17a02ead88913acaf70cf257221b82a5b8684655595cdc4a8952d4825fa19f23ab7004e5
|
|
7
|
+
data.tar.gz: ea5880cc6e73795cac795189e6a6d192e27fdd11a252768f1b33933b13407d9e1f929e1aaf4020112164dfa880acbe4601708f202795737b434df0f5a63202d6
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Diseño de la API pública — `generate_text` / `Agent`
|
|
2
|
+
|
|
3
|
+
Refinamiento de la propuesta de entry points. Conclusión previa: esta API **no es la Opción A**
|
|
4
|
+
(fachada barata). El momento en que `messages:` acepta roles genéricos, el modelo canónico de
|
|
5
|
+
mensajes se vuelve obligatorio — o sea, arrastra la Opción B por construcción. Eso es bueno: evita
|
|
6
|
+
construir A con mensajes provider-specific para tirarlo después.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Firmas propuestas (revisadas)
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
module Rasti
|
|
14
|
+
module AI
|
|
15
|
+
|
|
16
|
+
def self.generate_text(provider: nil, model: nil,
|
|
17
|
+
prompt: nil, messages: nil, system: nil,
|
|
18
|
+
tools: [], mcp_servers: {}, tool_choice: nil, max_steps: 1,
|
|
19
|
+
json_schema: nil, thinking: nil, max_tokens: nil, temperature: nil,
|
|
20
|
+
api_key: nil, usage_tracker: nil, logger: nil,
|
|
21
|
+
http_connect_timeout: nil, http_read_timeout: nil, http_max_retries: nil,
|
|
22
|
+
provider_options: {})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.generate_object(schema:, **options) # generate_text + json_schema + result.object
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class AI::Agent
|
|
31
|
+
|
|
32
|
+
def initialize(provider: nil, model: nil, state: nil,
|
|
33
|
+
system: nil, tools: [], mcp_servers: {}, tool_choice: nil, max_steps: 20,
|
|
34
|
+
json_schema: nil, thinking: nil, max_tokens: nil, temperature: nil,
|
|
35
|
+
api_key: nil, usage_tracker: nil, logger: nil,
|
|
36
|
+
http_connect_timeout: nil, http_read_timeout: nil, http_max_retries: nil,
|
|
37
|
+
provider_options: {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call(prompt) # => Result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`Rasti::AI.create_agent(...)` queda como alias de `Agent.new` si se quiere simetría con
|
|
48
|
+
`generate_text`, pero `Agent.new` es el idioma Ruby y debería ser el camino documentado.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. Decisiones y por qué
|
|
53
|
+
|
|
54
|
+
### 2.1 `prompt:` además de `messages:`
|
|
55
|
+
|
|
56
|
+
El caso mayoritario es un string. Que la firma mínima sea
|
|
57
|
+
`generate_text prompt: 'who is the best player'` vale más que la pureza de un solo parámetro.
|
|
58
|
+
`messages:` queda para control total. Son mutuamente excluyentes (`ArgumentError` si vienen los dos).
|
|
59
|
+
|
|
60
|
+
### 2.2 `system:` como parámetro, no como mensaje
|
|
61
|
+
|
|
62
|
+
Anthropic y Gemini ponen el system prompt **fuera** del array de mensajes (`system`,
|
|
63
|
+
`system_instruction`). Modelarlo como `{role: :system}` dentro de `messages:` obliga a que cada
|
|
64
|
+
adapter lo extraiga del array. Mejor que sea un campo del `Request`. Igual se acepta un mensaje con
|
|
65
|
+
`role: :system` en `messages:` y se normaliza hacia `system:` al construir el `Request`.
|
|
66
|
+
|
|
67
|
+
Esto además deja claro qué pasa con `state.context`: es el `system` del agente. Sugerencia:
|
|
68
|
+
`Agent.new(system:)` como nombre nuevo, `state.context` sigue funcionando como fuente si no se pasa
|
|
69
|
+
`system:`.
|
|
70
|
+
|
|
71
|
+
### 2.3 `provider:` acepta Symbol **o** instancia
|
|
72
|
+
|
|
73
|
+
Con `api_key:`, `usage_tracker:`, timeouts y `logger:` como kwargs planos, la firma tiene 8
|
|
74
|
+
parámetros que son de *transporte*, no de generación. Sirve para el caso simple, pero repetirlos en
|
|
75
|
+
cada llamada es ruido. Solución: que `provider:` acepte las dos formas.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Simple: símbolo, resuelve api_key y defaults de la config global
|
|
79
|
+
Rasti::AI.generate_text provider: :anthropic, model: 'claude-sonnet-4-5', prompt: '...'
|
|
80
|
+
|
|
81
|
+
# Reusable / multi-tenant: instancia con su propia credencial y transporte
|
|
82
|
+
provider = Rasti::AI.provider :anthropic, api_key: tenant.anthropic_key,
|
|
83
|
+
usage_tracker: ->(u) { tenant.track u },
|
|
84
|
+
http_read_timeout: 300
|
|
85
|
+
|
|
86
|
+
Rasti::AI.generate_text provider: provider, model: 'claude-sonnet-4-5', prompt: '...'
|
|
87
|
+
Rasti::AI::Agent.new provider: provider, model: 'claude-sonnet-4-5', tools: tools
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Los kwargs planos (`api_key:`, `usage_tracker:`, ...) siguen existiendo como atajo y se usan para
|
|
91
|
+
construir el provider internamente. Un provider explícito los ignora (o lanza `ArgumentError` si se
|
|
92
|
+
mezclan — a definir).
|
|
93
|
+
|
|
94
|
+
Beneficio concreto: `usage_tracker` per-session/per-tenant deja de necesitar un `Client` custom, que
|
|
95
|
+
es la única forma de hacerlo hoy.
|
|
96
|
+
|
|
97
|
+
### 2.4 `provider_options:` en vez de `**opts`
|
|
98
|
+
|
|
99
|
+
Un `**opts` que se reenvía al body es cómodo pero silencioso: `temparature: 0.5` no falla, se manda y
|
|
100
|
+
el provider lo ignora. Propuesta:
|
|
101
|
+
|
|
102
|
+
- Parámetros **normalizados** explícitos: `thinking`, `max_tokens`, `temperature`, `tool_choice`,
|
|
103
|
+
`max_steps`, `json_schema`. Cada adapter los traduce a su dialecto (como hoy con `thinking`).
|
|
104
|
+
- `provider_options:` — hash que se mergea crudo en el body del provider, escape hatch documentado.
|
|
105
|
+
Equivalente a `providerOptions` de Vercel.
|
|
106
|
+
|
|
107
|
+
Cualquier kwarg desconocido debe explotar. Sin `**opts` en la firma pública.
|
|
108
|
+
|
|
109
|
+
### 2.5 Valor de retorno: `Result`, no String
|
|
110
|
+
|
|
111
|
+
Hoy `Assistant#call` devuelve un String, y eso obliga a un `Client` custom para conocer el usage y
|
|
112
|
+
hace inaccesible el `finish_reason` y los tool calls ejecutados.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
result = Rasti::AI.generate_text provider: :openai, prompt: 'who is the best player'
|
|
116
|
+
|
|
117
|
+
result.text # => 'Lionel Messi'
|
|
118
|
+
result.object # => Hash parseado, cuando hay json_schema (hoy: hay que hacer JSON.parse a mano)
|
|
119
|
+
result.usage # => Usage (o Array de Usage si hubo varios pasos)
|
|
120
|
+
result.messages # => historial canónico completo
|
|
121
|
+
result.tool_calls # => [Part::ToolCall] ejecutados
|
|
122
|
+
result.steps # => [Step] request/response por iteración del loop
|
|
123
|
+
result.finish_reason # => :stop | :tool_calls | :max_steps | :length
|
|
124
|
+
result.raw # => payload crudo del último response
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Compatibilidad: `Result#to_s` devuelve `text`. Así `assistant.call('...')` interpolado en un string
|
|
128
|
+
o comparado con `==` sigue andando en la mayoría de los usos existentes. `Result#to_str` también, si
|
|
129
|
+
se quiere coerción implícita completa (a evaluar: puede enmascarar errores).
|
|
130
|
+
|
|
131
|
+
### 2.6 `json_schema:` acepta Hash **o** `Rasti::Form`
|
|
132
|
+
|
|
133
|
+
`ToolSerializer` ya convierte Forms a JSON Schema. Aceptar un Form cierra el círculo y evita escribir
|
|
134
|
+
schemas a mano. El Hash crudo actual sigue soportado.
|
|
135
|
+
|
|
136
|
+
`generate_object(schema:)` es el azúcar: valida que haya schema y expone `result.object` ya parseado.
|
|
137
|
+
|
|
138
|
+
### 2.7 `max_steps:`
|
|
139
|
+
|
|
140
|
+
Hoy `Assistant#call` es un `loop do` sin tope: un modelo que insiste en llamar tools cuelga el
|
|
141
|
+
proceso. `max_steps` con `finish_reason: :max_steps` cierra ese agujero. Default: `1` en
|
|
142
|
+
`generate_text` (one-shot explícito, y si hay tools se sube a mano) y `20` en `Agent`.
|
|
143
|
+
|
|
144
|
+
### 2.8 `state:` en el agente
|
|
145
|
+
|
|
146
|
+
Debe ser opcional (default `AssistantState.new`). Cuando el modelo canónico esté en su lugar,
|
|
147
|
+
`state.messages` es serializable a JSON y un thread se puede persistir y reanudar con otro provider.
|
|
148
|
+
Ese es el entregable que justifica el major.
|
|
149
|
+
|
|
150
|
+
Renombre sugerido: `AssistantState` → `Session` (o `Thread`), con alias de compatibilidad.
|
|
151
|
+
|
|
152
|
+
### 2.9 Streaming: no hacerlo ahora, no bloquearlo
|
|
153
|
+
|
|
154
|
+
`Provider#decode(raw_body)` sobre la respuesta completa no impide agregar después
|
|
155
|
+
`Provider#decode_stream(chunks)` que emita los mismos `Part` de forma incremental. Basta con no
|
|
156
|
+
asumir en el `Result` que todo llegó de una sola pieza. No entra en este alcance.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 3. Orden de implementación
|
|
161
|
+
|
|
162
|
+
La API pública se define primero (este documento) pero se **expone al final**, sobre base sólida.
|
|
163
|
+
|
|
164
|
+
| Paso | Alcance | Release |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| 1 | Modelo canónico: `Message`, `Part::*`, `ToolDefinition`, `Request`, `Response`, `Result`, `Step`. Base `Provider` con `encode`/`decode`/`parse_usage`/`endpoint`. Motor `Engine` con el loop (el `call` actual, sin template methods). Port del adapter OpenAI. Los `<Provider>::Assistant` actuales siguen funcionando sobre el motor nuevo vía shim. | interno |
|
|
167
|
+
| 2 | Port de Gemini, Anthropic, OpenRouter, HuaweiMaaS. Se borra: 5 `roles.rb`, `sanitize_schema` duplicado, los `Client` por provider (queda uno solo con auth pluggable), 12 template methods. | interno |
|
|
168
|
+
| 3 | Registry + `Rasti::AI.provider`, `generate_text`, `generate_object`, `Agent`, config `default_provider` / `default_model`. Alias de compatibilidad. README nuevo. | 4.0.0 |
|
|
169
|
+
|
|
170
|
+
Cada paso deja la suite verde. Los fixtures ERB de `spec/resources/` se reutilizan tal cual: pasan a
|
|
171
|
+
testear `encode`/`decode` como funciones puras, sin WebMock, y el loop se testea una sola vez con un
|
|
172
|
+
provider fake.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 4. Puntos abiertos
|
|
177
|
+
|
|
178
|
+
1. ¿`<Provider>::Client` sobrevive como API pública (shim `chat_completions`/`messages`/
|
|
179
|
+
`generate_content`) o se deprecia en 4.0?
|
|
180
|
+
2. ¿`Result#to_str` (coerción implícita) o sólo `to_s`? `to_str` maximiza compatibilidad pero puede
|
|
181
|
+
ocultar bugs.
|
|
182
|
+
3. ¿`Agent` reemplaza a `Assistant` con alias, o conviven documentados?
|
|
183
|
+
4. Mezclar `provider:` instancia con kwargs de transporte: ¿ignorar en silencio o `ArgumentError`?
|
|
184
|
+
5. `usage` en el `Result` con varios pasos: ¿array de `Usage` o un `Usage` agregado (+ `#usages`)?
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Normalización de la API de Rasti::AI
|
|
2
|
+
|
|
3
|
+
Objetivo: poder elegir provider y modelo **por configuración o por parámetro**, y usar la gema
|
|
4
|
+
para (a) pegarle directo a un modelo y obtener una respuesta, o (b) armar un agente con tools,
|
|
5
|
+
estado y structured output — sin importar el provider. Referencias de diseño: Vercel AI SDK
|
|
6
|
+
(`generateText({ model })`, `LanguageModelV2`) y pi (`agent`, provider/model como strings).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Diagnóstico: qué impide hoy la unificación
|
|
11
|
+
|
|
12
|
+
| Acoplamiento | Dónde | Consecuencia |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Los mensajes se guardan con el shape del provider | `AssistantState#messages` + los 5 `build_*_message` | No se puede cambiar de provider en una conversación existente, ni persistir/reanudar una conversación con otro provider |
|
|
15
|
+
| El nombre del método de API difiere | `chat_completions` / `messages` / `generate_content` | El `Assistant` no puede hablarle a un `Client` genérico; `request_completion` es template method |
|
|
16
|
+
| La respuesta se parsea "in situ" | `parse_content`, `parse_tool_calls`, `finished?`, `extract_tool_call_info` | 4 de los 12 template methods son sólo "leer el JSON crudo" |
|
|
17
|
+
| La serialización de tools se re-envuelve por provider | `wrap_tool_serialization`, `extract_tool_name`, `sanitize_schema` duplicado en Gemini y Anthropic | Código copiado; `ToolSerializer` emite `inputSchema` y cada uno lo renombra |
|
|
18
|
+
| Un `Roles` por provider | 5 archivos `roles.rb` | 4 constantes con 3 valores distintos en total (`model`/`function` de Gemini) |
|
|
19
|
+
| La clase elegida ES el provider | `Rasti::AI::OpenAI::Assistant` | La elección de provider es una constante en el código del usuario, no un dato |
|
|
20
|
+
|
|
21
|
+
Números: de ~1.540 líneas de `lib`, los adaptadores de provider son ~700, y buena parte es
|
|
22
|
+
duplicación mecánica (`sanitize_schema` está literalmente dos veces).
|
|
23
|
+
|
|
24
|
+
Observación clave: **el loop de `Assistant#call` ya es universal**. Lo único que cambia por
|
|
25
|
+
provider es la traducción de ida (request) y de vuelta (response). Eso es exactamente la
|
|
26
|
+
frontera que hay que aislar.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 2. Opción A — Fachada + registry (compatible, sin romper nada)
|
|
31
|
+
|
|
32
|
+
Agregar una capa de resolución arriba de lo que ya existe. Cero cambios en las clases actuales.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Rasti::AI.configure do |config|
|
|
36
|
+
config.default_provider = :anthropic
|
|
37
|
+
config.default_model = 'claude-sonnet-4-5'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One-shot, sin agente
|
|
41
|
+
Rasti::AI.generate 'who is the best player'
|
|
42
|
+
Rasti::AI.generate 'who is the best player', model: 'openai:gpt-4o'
|
|
43
|
+
|
|
44
|
+
# Agente, provider como dato
|
|
45
|
+
assistant = Rasti::AI.assistant provider: :gemini, model: 'gemini-2.0-flash', tools: tools
|
|
46
|
+
assistant.call 'what is the weather in Buenos Aires'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Implementación:
|
|
50
|
+
|
|
51
|
+
- `Rasti::AI::Providers` — registry `{openai: OpenAI::Assistant, ...}` + `register`.
|
|
52
|
+
- Parseo de `'provider:model'` (también `'provider/model'`) para resolver ambos de un string.
|
|
53
|
+
- `Rasti::AI.assistant(...)` instancia la clase concreta y devuelve el objeto actual.
|
|
54
|
+
- `Rasti::AI.generate(prompt, **opts)` = assistant efímero + `#call`.
|
|
55
|
+
|
|
56
|
+
| Pros | Contras |
|
|
57
|
+
|---|---|
|
|
58
|
+
| No rompe nada; release 3.2.0 | No elimina una sola línea de duplicación |
|
|
59
|
+
| Muy barato (~1 día con tests) | `state.messages` sigue siendo provider-specific → no se puede migrar una conversación |
|
|
60
|
+
| Habilita ya el "provider por config" | Agregar un provider sigue costando 12 template methods |
|
|
61
|
+
| Es un paso válido *hacia* B (la fachada sobrevive) | La API pública queda con dos formas de hacer lo mismo |
|
|
62
|
+
|
|
63
|
+
Veredicto: útil como **paso 1**, insuficiente como solución.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 3. Opción B — Mensajes canónicos + adaptadores (la normalización real)
|
|
68
|
+
|
|
69
|
+
Un solo `Assistant`/`Agent`. El provider pasa a ser un objeto con dos responsabilidades:
|
|
70
|
+
codificar un request canónico y decodificar una respuesta cruda a un objeto canónico.
|
|
71
|
+
|
|
72
|
+
### 3.1 Modelo canónico
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
Rasti::AI::Message # role: :user | :assistant | :tool, parts: [...]
|
|
76
|
+
Rasti::AI::Part::Text # text
|
|
77
|
+
Rasti::AI::Part::ToolCall # id, name, arguments (Hash)
|
|
78
|
+
Rasti::AI::Part::ToolResult # tool_call_id, name, content
|
|
79
|
+
Rasti::AI::Part::Reasoning # text, signature, raw (thinking blocks de Anthropic)
|
|
80
|
+
Rasti::AI::ToolDefinition # name, description, input_schema (JSON Schema neutro)
|
|
81
|
+
|
|
82
|
+
Rasti::AI::Request # messages, system, tools, tool_choice, model, thinking, json_schema, max_tokens
|
|
83
|
+
Rasti::AI::Response # message, finish_reason, usage, raw
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`Reasoning` con `raw` es importante: Anthropic exige devolver los thinking blocks intactos en el
|
|
87
|
+
turno siguiente. Guardando el `raw` del part, el encode los reinyecta sin que el modelo canónico
|
|
88
|
+
tenga que entender el formato.
|
|
89
|
+
|
|
90
|
+
### 3.2 Adaptador
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class Rasti::AI::Provider # base abstracta
|
|
94
|
+
|
|
95
|
+
def encode(request) # => Hash (body del provider)
|
|
96
|
+
def decode(raw) # => Rasti::AI::Response
|
|
97
|
+
def parse_usage(raw) # => Usage
|
|
98
|
+
def endpoint(request)
|
|
99
|
+
def client # HTTP genérico, hoy Rasti::AI::Client sin subclases por provider
|
|
100
|
+
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Cada provider implementa **4 métodos en vez de 12** (+ auth headers). El `sanitize_schema`
|
|
105
|
+
compartido vive en un `SchemaSanitizer` con la lista de campos permitidos como constante del
|
|
106
|
+
provider. `Roles` desaparece como concepto público: los roles canónicos son símbolos y cada
|
|
107
|
+
adapter tiene su tabla de traducción.
|
|
108
|
+
|
|
109
|
+
### 3.3 Assistant único
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class Rasti::AI::Assistant
|
|
113
|
+
|
|
114
|
+
def initialize(provider: nil, model: nil, tools: [], mcp_servers: {}, json_schema: nil,
|
|
115
|
+
thinking: nil, state: nil, client: nil, logger: nil)
|
|
116
|
+
|
|
117
|
+
def call(prompt) # mismo loop de hoy, sin template methods
|
|
118
|
+
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
El loop no cambia: `messages << user` → `provider.decode(client.post(provider.endpoint, provider.encode(request)))`
|
|
123
|
+
→ si hay `ToolCall` parts, ejecutar y agregar `ToolResult` → si no, devolver el texto.
|
|
124
|
+
|
|
125
|
+
### 3.4 Lo que se gana
|
|
126
|
+
|
|
127
|
+
- **Portabilidad de conversación**: `state.messages` es canónico y serializable a JSON. Se puede
|
|
128
|
+
persistir un thread, y reanudarlo con otro provider/modelo. Hoy es imposible.
|
|
129
|
+
- **Fallback / routing**: reintentar el mismo request contra otro provider ante rate limit o 5xx.
|
|
130
|
+
- **Un solo lugar** para tool calling, structured output y thinking.
|
|
131
|
+
- **Agregar provider** = 1 archivo, ~4 métodos.
|
|
132
|
+
- **Testing**: se puede testear el loop con un `Provider` fake, sin WebMock, y testear cada
|
|
133
|
+
adapter como función pura `encode/decode` contra los fixtures ERB que ya existen.
|
|
134
|
+
|
|
135
|
+
### 3.5 Costo y compatibilidad
|
|
136
|
+
|
|
137
|
+
Rotura real y única: el shape de `state.messages`. Todo lo demás se puede preservar:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
module Rasti::AI::OpenAI
|
|
141
|
+
class Assistant < Rasti::AI::Assistant
|
|
142
|
+
def initialize(**options)
|
|
143
|
+
super(**options, provider: :open_ai)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`Rasti::AI::OpenAI::Client` se puede mantener como shim (`chat_completions` → encode/post) para
|
|
150
|
+
quien lo use directo, o marcarlo deprecado. Los `Roles` se pueden dejar como constantes vivas.
|
|
151
|
+
|
|
152
|
+
Es un **major (4.0.0)** por el cambio de `state.messages`, con las clases por provider intactas
|
|
153
|
+
como alias. Esfuerzo estimado: 3-5 días incluyendo migración de specs.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 4. Opción C — API funcional estilo Vercel/pi encima de B
|
|
158
|
+
|
|
159
|
+
Con B en su lugar, la superficie pública se puede rediseñar para que el uso más común sea una
|
|
160
|
+
línea. Esto es *aditivo* sobre B, no una alternativa.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# Texto
|
|
164
|
+
result = Rasti::AI.generate_text model: 'anthropic:claude-sonnet-4-5',
|
|
165
|
+
system: 'Act as sports journalist',
|
|
166
|
+
prompt: 'who is the best player'
|
|
167
|
+
|
|
168
|
+
result.text # => 'Lionel Messi'
|
|
169
|
+
result.usage # => Usage
|
|
170
|
+
result.messages # => historial canónico
|
|
171
|
+
result.steps # => [Step] con tool calls/results por iteración
|
|
172
|
+
result.finish_reason # => :stop
|
|
173
|
+
|
|
174
|
+
# Objeto estructurado
|
|
175
|
+
result = Rasti::AI.generate_object model: 'openai:gpt-4o',
|
|
176
|
+
schema: PlayerForm, # Rasti::Form → JSON Schema
|
|
177
|
+
prompt: 'who is the best player'
|
|
178
|
+
result.object # => {player: 'Lionel Messi', sport: 'Football'}
|
|
179
|
+
|
|
180
|
+
# Agente reutilizable con estado
|
|
181
|
+
agent = Rasti::AI::Agent.new model: 'gemini:gemini-2.0-flash',
|
|
182
|
+
instructions: 'Act as sports journalist',
|
|
183
|
+
tools: [GetCurrentWeather.new],
|
|
184
|
+
mcp_servers: {weather: mcp_client},
|
|
185
|
+
thinking: 'medium'
|
|
186
|
+
|
|
187
|
+
agent.call 'what is the weather in Buenos Aires'
|
|
188
|
+
agent.call 'and tomorrow?' # mismo thread
|
|
189
|
+
agent.messages # canónico, serializable
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Ideas adicionales que este diseño habilita naturalmente:
|
|
193
|
+
|
|
194
|
+
- **`schema:` acepta un `Rasti::Form`** en vez de un Hash a mano — cierra el círculo con
|
|
195
|
+
`ToolSerializer`, que ya sabe convertir Forms a JSON Schema. El `json_schema:` actual (Hash
|
|
196
|
+
crudo) sigue funcionando.
|
|
197
|
+
- **`Rasti::AI.model('openai:gpt-4o')`** como objeto de primera clase, reutilizable e inyectable:
|
|
198
|
+
`Agent.new model: Rasti::AI.model('...', api_key: tenant.key)` → multi-tenant sin globals.
|
|
199
|
+
- **Middleware** (`wrapLanguageModel` de Vercel): un provider decorado para logging, caché de
|
|
200
|
+
respuestas, usage tracking, redacción de PII, o fallback. `usage_tracker` pasa a ser un
|
|
201
|
+
middleware más y deja de estar hardcodeado en el `Client`.
|
|
202
|
+
- **`max_steps:`** para acotar el loop de tools (hoy `loop do` no tiene tope: un modelo que
|
|
203
|
+
insiste en llamar tools puede colgar el proceso indefinidamente — es un bug latente).
|
|
204
|
+
- **Nombres**: `Assistant` → `Agent`, `AssistantState` → `Thread`/`Session`. Alinea con el
|
|
205
|
+
vocabulario de la industria y con lo que la clase realmente es.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 5. Recomendación
|
|
210
|
+
|
|
211
|
+
Camino en dos releases, sin trabajo tirado:
|
|
212
|
+
|
|
213
|
+
1. **3.2.0 — Opción A.** Registry de providers + `Rasti::AI.assistant(provider:, model:)` +
|
|
214
|
+
`Rasti::AI.generate` + `default_provider`. Compatible. Deja la elección de provider como dato
|
|
215
|
+
y define la superficie pública que va a sobrevivir al refactor.
|
|
216
|
+
2. **4.0.0 — Opción B + C.** Mensajes canónicos y adaptadores por debajo; `generate_text` /
|
|
217
|
+
`generate_object` / `Agent` por arriba. Las clases `<Provider>::Assistant` quedan como
|
|
218
|
+
subclases de una línea, así que el código existente sigue compilando salvo quien inspeccione
|
|
219
|
+
`state.messages`.
|
|
220
|
+
|
|
221
|
+
Dos puntos a decidir antes de arrancar:
|
|
222
|
+
|
|
223
|
+
- ¿Se mantiene `<Provider>::Client` como API pública (shim) o se deprecia? Afecta cuánto código
|
|
224
|
+
legacy sobrevive en 4.0.
|
|
225
|
+
- ¿`Agent` reemplaza a `Assistant` o convive? Sugerencia: `Agent` como nombre nuevo y
|
|
226
|
+
`Assistant = Agent` como alias, deprecando en 5.0.
|
|
227
|
+
|
|
228
|
+
Restricción a respetar en toda la implementación: **Ruby 2.3**. Sin pattern matching, sin
|
|
229
|
+
`transform_keys`, sin `Hash#slice`. Los objetos canónicos con `Rasti::Model` (que ya es
|
|
230
|
+
dependencia) resuelven la parte de tipado sin agregar nada.
|
data/AGENTS.md
CHANGED
|
@@ -4,27 +4,115 @@ Developer and agent reference. For usage examples see README.md.
|
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
|
+
### Provider
|
|
8
|
+
|
|
9
|
+
`Rasti::AI::Provider` is the entry point that makes provider and model a runtime value instead of a class reference. An instance holds the transport configuration (`api_key`, `usage_tracker`, `logger`, HTTP timeouts) plus the default `model`, and knows how to build the provider's `Client` and `Assistant`.
|
|
10
|
+
|
|
11
|
+
The set of providers is **fixed** — there is no registry and third-party providers are not supported. Two frozen constants in the base class drive resolution:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
MODULES = {open_ai: 'OpenAI', gemini: 'Gemini', anthropic: 'Anthropic', open_router: 'OpenRouter', huawei_maas: 'HuaweiMaaS'}.freeze
|
|
15
|
+
ALIASES = {openai: :open_ai, openrouter: :open_router, huawei: :huawei_maas}.freeze
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`Provider.build(name, **options)` normalizes the name (`downcase.to_sym`), applies aliases, and resolves the class with `AI.const_get(MODULES[key])::Provider` — at call time, so there is no load-order constraint. Passing an existing `Provider` instance returns it untouched. Unknown names raise `Errors::UnknownProvider`.
|
|
19
|
+
|
|
20
|
+
`client_class` / `assistant_class` are derived from `name` through `provider_module`, so `OpenRouter::Provider` (which inherits from `OpenAI::Provider`) gets `OpenRouter::Client` and `OpenRouter::Assistant` without overriding anything.
|
|
21
|
+
|
|
22
|
+
#### Provider — methods to implement (7 total)
|
|
23
|
+
|
|
24
|
+
Public — also consumed by the `Client`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
def name # :open_ai — also used by provider_module
|
|
28
|
+
def default_model # from Rasti::AI config
|
|
29
|
+
def default_api_key # from Rasti::AI config
|
|
30
|
+
def base_url # e.g. 'https://api.anthropic.com/v1'
|
|
31
|
+
def parse_usage(response) # raw response => Usage or nil
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Private — the request/response translation:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
def request(client:, messages:, system:, model:, json_schema:, thinking:) # calls the client, returns raw response
|
|
38
|
+
def encode_message(message) # {role:, content:} with generic roles => provider message
|
|
39
|
+
def parse_content(response) # raw response => String
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Plus a `THINKING_LEVELS` constant (see [Thinking levels](#thinking-levels)). OpenAI-compatible providers only override `name`, `default_model` and `default_api_key`.
|
|
43
|
+
|
|
44
|
+
#### Public API
|
|
45
|
+
|
|
46
|
+
- **`Provider#generate_text(prompt:/messages:, system:, model:, json_schema:, thinking:, client:)`** — one request, no tools, no loop. Normalizes generic-role messages, extracts `system` messages into the provider's system field, and returns a `Result`.
|
|
47
|
+
- **`Provider#create_assistant(state:, system:, model:, tools:, mcp_servers:, json_schema:, thinking:, client:)`** — builds the provider-specific `Assistant`. `system:` is sugar for `state: AssistantState.new(context: ...)`; passing both raises `ArgumentError`.
|
|
48
|
+
- **`Provider#build_client`** — the provider's `Client` with the instance's transport options.
|
|
49
|
+
- **`Rasti::AI.provider`, `Rasti::AI.generate_text`, `Rasti::AI.create_assistant`** — thin module-level wrappers. They split transport options (used to build the provider) from call options (forwarded to the provider method).
|
|
50
|
+
|
|
51
|
+
`model:` accepts `'provider:model'` (split on the first `:`), but only when `provider:` is not given explicitly. The provider falls back to `Rasti::AI.default_provider` (`ENV['AI_DEFAULT_PROVIDER']`); nil raises `ArgumentError`. `model` on a `Provider` is only its default — every call accepts a `model:` override.
|
|
52
|
+
|
|
53
|
+
The private helpers `build_provider` and `split_provider_and_model` live in a `class << self` block with `private` inside it.
|
|
54
|
+
|
|
55
|
+
### Result
|
|
56
|
+
|
|
57
|
+
`Rasti::AI::Result` (a `Rasti::Model`) is the normalized output of `generate_text`:
|
|
58
|
+
|
|
59
|
+
| Attribute | Content |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `content` | The text the model returned, or the JSON string when `json_schema` is used |
|
|
62
|
+
| `usage` | A `Usage` instance, from `Provider#parse_usage` |
|
|
63
|
+
| `raw` | The untouched provider payload |
|
|
64
|
+
|
|
65
|
+
`Provider#parse_usage` is public because the `Client` also calls it, from `track_usage`, on every response of the assistant's tool loop.
|
|
66
|
+
|
|
67
|
+
`Assistant#call` still returns a plain `String` — unchanged for compatibility.
|
|
68
|
+
|
|
69
|
+
### Generic roles
|
|
70
|
+
|
|
71
|
+
`Rasti::AI::Roles` (`USER`, `ASSISTANT`, `SYSTEM`) is the provider-agnostic vocabulary used by `generate_text(messages:)`. Each `Provider#encode_message` translates it (Gemini maps `assistant` to its `model` role; `system` messages are pulled out of the array by `split_system` and passed through the provider's system field).
|
|
72
|
+
|
|
73
|
+
The per-provider `Roles` modules still exist and are what the `Assistant` classes use. Inside `Rasti::AI::<Provider>::*`, a bare `Roles::USER` resolves to the provider's module by lexical scope — use the fully qualified `Rasti::AI::Roles::USER` for the generic one.
|
|
74
|
+
|
|
75
|
+
> ⚠️ Do not reference a provider's `Roles` constants in a constant definition inside `<provider>/provider.rb`: `multi_require` loads `provider.rb` **before** `roles.rb` (alphabetical order). Resolve them inside method bodies instead (see `Gemini::Provider#encode_role`).
|
|
76
|
+
|
|
77
|
+
#### Known duplication
|
|
78
|
+
|
|
79
|
+
`Anthropic::Provider` reimplements the structured-output tool and its parsing, which also live in `Anthropic::Assistant` (Anthropic has no native `json_schema` support). Same for Gemini's `generation_config`. This is temporary: once the `Assistant` is rebuilt on top of `Provider#request`, both collapse into one. Keep them in sync until then.
|
|
80
|
+
|
|
7
81
|
### Template method pattern
|
|
8
82
|
|
|
9
83
|
`Rasti::AI::Client` and `Rasti::AI::Assistant` are abstract base classes. Provider-specific subclasses implement a fixed set of methods; all shared logic (HTTP retries, tool caching, the request/response loop) lives in the base.
|
|
10
84
|
|
|
11
85
|
#### Client — methods to implement
|
|
12
86
|
|
|
87
|
+
Nothing is mandatory. A client only defines the provider's endpoint method (`chat_completions`, `messages`, `generate_content`) and, if the API needs credentials in the request, one of:
|
|
88
|
+
|
|
13
89
|
```ruby
|
|
14
|
-
def default_api_key # reads from Rasti::AI config
|
|
15
|
-
def base_url # e.g. 'https://api.anthropic.com/v1'
|
|
16
|
-
def parse_usage(response) # returns a Usage instance or nil
|
|
17
|
-
# optionally override:
|
|
18
90
|
def build_request(uri) # super + add auth headers
|
|
19
|
-
def build_url(relative_url) # super
|
|
91
|
+
def build_url(relative_url) # super + query params (e.g. Gemini key)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Everything else — API key, default model, base URL, usage parsing — lives in the `Provider`, and the client delegates (`provider` and `provider_module` come from the `ProviderAware` module, shared with `Assistant`):
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
def default_api_key ; provider.default_api_key ; end
|
|
98
|
+
def default_model ; provider.default_model ; end
|
|
99
|
+
def base_url ; provider.base_url ; end
|
|
100
|
+
def track_usage(response) ; ... provider.parse_usage(response) ... ; end
|
|
20
101
|
```
|
|
21
102
|
|
|
103
|
+
As a result `OpenRouter::Client` and `HuaweiMaaS::Client` have **empty bodies**. Keep them: they are the namespace marker that `Provider#client_class` and `ProviderAware#provider_module` resolve against, and they keep `Rasti::AI::<Provider>::Client.new` working as a documented entry point.
|
|
104
|
+
|
|
105
|
+
`Provider#build_client` injects itself (`provider: self`). When a client is built standalone (`OpenAI::Client.new`) the private `provider` method lazily builds the provider of its own namespace, derived from the class name — so nothing breaks and no provider-specific config is duplicated.
|
|
106
|
+
|
|
107
|
+
> ⚠️ In `Client#initialize`, the `provider:` keyword argument shadows the private `provider` method. That's why the fallback goes through `default_api_key` instead of calling `provider.default_api_key` inline.
|
|
108
|
+
|
|
109
|
+
> ⚠️ The class-name derivation means an anonymous client subclass (`Class.new(OpenAI::Client)`) has no provider. Subclass explicitly if you need one in a test.
|
|
110
|
+
|
|
22
111
|
The base `post` method handles JSON serialization, logging, retries on network errors and 5xx, and calls `track_usage` after each successful response.
|
|
23
112
|
|
|
24
|
-
#### Assistant — methods to implement (
|
|
113
|
+
#### Assistant — methods to implement (11 total)
|
|
25
114
|
|
|
26
115
|
```ruby
|
|
27
|
-
def build_default_client # Client.new
|
|
28
116
|
def build_user_message(prompt) # {role: ..., content: prompt}
|
|
29
117
|
def build_assistant_message(content)
|
|
30
118
|
def build_assistant_tool_calls_message(response)
|
|
@@ -40,6 +128,10 @@ def extract_tool_name(wrapped) # string name from wrapped tool hash
|
|
|
40
128
|
|
|
41
129
|
The base `call` loop: add user message → request completion → if tool calls: execute each, add results, loop → else: return content.
|
|
42
130
|
|
|
131
|
+
The client is **not** a template method: the base `Assistant` gets it from its provider (`provider.build_client`), lazily, so nothing is built until the first request. As with `Client`, `Provider#create_assistant` injects `provider: self`, and direct instantiation (`Rasti::AI::OpenRouter::Assistant.new`) falls back to the namespace derivation.
|
|
132
|
+
|
|
133
|
+
That leaves `OpenRouter::Assistant` and `HuaweiMaaS::Assistant` with **empty bodies** — like their clients, they survive as the documented public entry point per provider and as the namespace marker `Provider#assistant_class` resolves.
|
|
134
|
+
|
|
43
135
|
#### Tool — optional base class
|
|
44
136
|
|
|
45
137
|
Simple tool classes only need a `.form` class method and a `#call(params={})` instance method.
|
|
@@ -68,22 +160,31 @@ lib/
|
|
|
68
160
|
roles.rb
|
|
69
161
|
client.rb
|
|
70
162
|
assistant.rb
|
|
163
|
+
provider.rb
|
|
71
164
|
gemini/
|
|
72
165
|
roles.rb
|
|
73
166
|
client.rb
|
|
74
167
|
assistant.rb
|
|
168
|
+
provider.rb
|
|
75
169
|
anthropic/
|
|
76
170
|
roles.rb
|
|
77
171
|
client.rb
|
|
78
172
|
assistant.rb
|
|
173
|
+
provider.rb
|
|
79
174
|
open_router/
|
|
80
175
|
roles.rb
|
|
81
176
|
client.rb
|
|
82
177
|
assistant.rb
|
|
178
|
+
provider.rb
|
|
83
179
|
huawei_maas/
|
|
84
180
|
roles.rb
|
|
85
181
|
client.rb
|
|
86
182
|
assistant.rb
|
|
183
|
+
provider.rb
|
|
184
|
+
provider.rb # entry point: resolves provider/model, builds client + assistant, generate_text
|
|
185
|
+
provider_aware.rb # shared lazy provider lookup by namespace (Client + Assistant)
|
|
186
|
+
result.rb # normalized generate_text output (content, usage, raw)
|
|
187
|
+
roles.rb # generic roles (user, assistant, system)
|
|
87
188
|
mcp/
|
|
88
189
|
server.rb # Rack middleware exposing tools via JSON-RPC 2.0
|
|
89
190
|
tools_registry.rb # per-request tool registry used by the middleware
|
|
@@ -139,6 +240,7 @@ spec/
|
|
|
139
240
|
| | OpenAI | Gemini | Anthropic |
|
|
140
241
|
|---|---|---|---|
|
|
141
242
|
| Auth | `Authorization: Bearer {key}` | `?key=` query param | `x-api-key: {key}` + `anthropic-version: 2023-06-01` header |
|
|
243
|
+
| Usage payload | `usage.prompt_tokens` / `completion_tokens` | `usageMetadata.promptTokenCount` / `candidatesTokenCount` | `usage.input_tokens` / `output_tokens` |
|
|
142
244
|
| Endpoint | `POST /chat/completions` | `POST /models/{model}:generateContent` | `POST /messages` |
|
|
143
245
|
| System prompt | message with `role: system` | top-level `system_instruction` | top-level `system` string |
|
|
144
246
|
| `max_tokens` | optional | optional | **required** (default 4096 in client) |
|
|
@@ -156,7 +258,7 @@ spec/
|
|
|
156
258
|
|
|
157
259
|
### OpenAI-compatible providers
|
|
158
260
|
|
|
159
|
-
OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth.
|
|
261
|
+
OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth. They inherit from the OpenAI provider; their whole implementation is `name` / `default_model` / `default_api_key` / `base_url` on the provider. Their clients and assistants are empty subclasses.
|
|
160
262
|
|
|
161
263
|
| | OpenRouter | Huawei MaaS |
|
|
162
264
|
|---|---|---|
|
|
@@ -178,6 +280,8 @@ The base `Assistant` accepts `thinking: 'low' | 'medium' | 'high'` (validated on
|
|
|
178
280
|
| `'medium'` | `'medium'` | `8_000` | `8_192` |
|
|
179
281
|
| `'high'` | `'high'` | `16_000` | `24_576` |
|
|
180
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
|
+
|
|
181
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).
|
|
182
286
|
|
|
183
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.
|
|
@@ -187,11 +291,12 @@ The loop does not change. Anthropic thinking blocks (`type: 'thinking'`) in resp
|
|
|
187
291
|
|
|
188
292
|
## Adding a new provider
|
|
189
293
|
|
|
190
|
-
Create
|
|
294
|
+
Create four files under `lib/rasti/ai/<provider>/`:
|
|
191
295
|
|
|
192
296
|
1. **`roles.rb`** — string constants for role names
|
|
193
|
-
2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method +
|
|
194
|
-
3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all
|
|
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`
|
|
195
300
|
|
|
196
301
|
Add to `lib/rasti/ai.rb`:
|
|
197
302
|
```ruby
|
|
@@ -199,6 +304,10 @@ attr_config :<provider>_api_key, ENV['<PROVIDER>_API_KEY']
|
|
|
199
304
|
attr_config :<provider>_default_model, ENV['<PROVIDER>_DEFAULT_MODEL']
|
|
200
305
|
```
|
|
201
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
|
+
|
|
202
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`.
|
|
203
312
|
|
|
204
313
|
Add an entry to the `PROVIDERS` table in `tasks/assistant.rake` so the interactive task is also available for the new provider:
|
|
@@ -220,12 +329,13 @@ config.<provider>_default_model = '<provider>-test'
|
|
|
220
329
|
|
|
221
330
|
### OpenAI-compatible providers
|
|
222
331
|
|
|
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::
|
|
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:
|
|
224
333
|
|
|
225
|
-
|
|
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`
|
|
226
337
|
|
|
227
|
-
|
|
228
|
-
- **`provider_name`** — the string written to `Usage#provider`
|
|
338
|
+
`Usage#provider` comes from `Provider#name`, so nothing else needs to know the provider's identity.
|
|
229
339
|
|
|
230
340
|
See `open_router/` and `huawei_maas/` for reference implementations.
|
|
231
341
|
|