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
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.
|