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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.features/api-normalization/api-design.md +184 -0
  3. data/.features/api-normalization/overview.md +230 -0
  4. data/AGENTS.md +125 -15
  5. data/README.md +143 -19
  6. data/lib/rasti/ai/anthropic/assistant.rb +1 -11
  7. data/lib/rasti/ai/anthropic/client.rb +6 -28
  8. data/lib/rasti/ai/anthropic/provider.rb +87 -0
  9. data/lib/rasti/ai/assistant.rb +10 -11
  10. data/lib/rasti/ai/client.rb +13 -10
  11. data/lib/rasti/ai/errors.rb +8 -0
  12. data/lib/rasti/ai/gemini/assistant.rb +1 -11
  13. data/lib/rasti/ai/gemini/client.rb +2 -24
  14. data/lib/rasti/ai/gemini/provider.rb +90 -0
  15. data/lib/rasti/ai/huawei_maas/assistant.rb +0 -7
  16. data/lib/rasti/ai/huawei_maas/client.rb +0 -19
  17. data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
  18. data/lib/rasti/ai/huawei_maas/roles.rb +1 -8
  19. data/lib/rasti/ai/open_ai/assistant.rb +0 -4
  20. data/lib/rasti/ai/open_ai/client.rb +3 -33
  21. data/lib/rasti/ai/open_ai/provider.rb +74 -0
  22. data/lib/rasti/ai/open_router/assistant.rb +0 -7
  23. data/lib/rasti/ai/open_router/client.rb +0 -19
  24. data/lib/rasti/ai/open_router/provider.rb +25 -0
  25. data/lib/rasti/ai/open_router/roles.rb +1 -8
  26. data/lib/rasti/ai/provider.rb +197 -0
  27. data/lib/rasti/ai/provider_aware.rb +17 -0
  28. data/lib/rasti/ai/result.rb +11 -0
  29. data/lib/rasti/ai/roles.rb +11 -0
  30. data/lib/rasti/ai/version.rb +1 -1
  31. data/lib/rasti/ai.rb +93 -0
  32. data/spec/anthropic/provider_spec.rb +106 -0
  33. data/spec/gemini/provider_spec.rb +90 -0
  34. data/spec/open_ai/provider_spec.rb +101 -0
  35. data/spec/rasti_ai_spec.rb +322 -0
  36. data/spec/resources/open_ai/conversation_request.json +1 -0
  37. data/spec/resources/open_ai/system_request.json +1 -0
  38. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81ed3eb7239ec9d47de179db57b6d55abb21c512ee6c830de189c77692bb1bf0
4
- data.tar.gz: fb25a1ab3f55861676625021c91bfed4aea5c40058c3b6282041dadd39c9fdb3
3
+ metadata.gz: fa07486ef7216a0a1408d1711ea21ca521283673c06ca2845ad63197dd1dbe8d
4
+ data.tar.gz: dd42552dfd7e3752dac0264b1c89159f04f230c6d067fa674dd4347aae01b0b1
5
5
  SHA512:
6
- metadata.gz: f3a1f07c3580de138323486de7255633eb67e1e14a3ea7ec847fededaf6f2ba771cf425d5ac27f80ed7180582e8b2070562ff42c84a1dbedbc541da9e1bb6a59
7
- data.tar.gz: afa8bf99945c06a833b62ae3d53e42860b49a923715473b8a1cb8b4793dc653b9d48b93a991676d7cc79c79aa714bc2a8d6c94156c9fdb36f60bea125f660936
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 unless URL needs query params (e.g. Gemini key)
91
+ def build_url(relative_url) # super + query params (e.g. Gemini key)
92
+ ```
93
+
94
+ Everything else — API key, default model, base URL, usage parsing — lives in the `Provider`, and the client delegates (`provider` and `provider_module` come from the `ProviderAware` module, shared with `Assistant`):
95
+
96
+ ```ruby
97
+ def default_api_key ; provider.default_api_key ; end
98
+ def default_model ; provider.default_model ; end
99
+ def base_url ; provider.base_url ; end
100
+ def track_usage(response) ; ... provider.parse_usage(response) ... ; end
20
101
  ```
21
102
 
103
+ As a result `OpenRouter::Client` and `HuaweiMaaS::Client` have **empty bodies**. Keep them: they are the namespace marker that `Provider#client_class` and `ProviderAware#provider_module` resolve against, and they keep `Rasti::AI::<Provider>::Client.new` working as a documented entry point.
104
+
105
+ `Provider#build_client` injects itself (`provider: self`). When a client is built standalone (`OpenAI::Client.new`) the private `provider` method lazily builds the provider of its own namespace, derived from the class name — so nothing breaks and no provider-specific config is duplicated.
106
+
107
+ > ⚠️ In `Client#initialize`, the `provider:` keyword argument shadows the private `provider` method. That's why the fallback goes through `default_api_key` instead of calling `provider.default_api_key` inline.
108
+
109
+ > ⚠️ The class-name derivation means an anonymous client subclass (`Class.new(OpenAI::Client)`) has no provider. Subclass explicitly if you need one in a test.
110
+
22
111
  The base `post` method handles JSON serialization, logging, retries on network errors and 5xx, and calls `track_usage` after each successful response.
23
112
 
24
- #### Assistant — methods to implement (12 total)
113
+ #### Assistant — methods to implement (11 total)
25
114
 
26
115
  ```ruby
27
- def build_default_client # Client.new
28
116
  def build_user_message(prompt) # {role: ..., content: prompt}
29
117
  def build_assistant_message(content)
30
118
  def build_assistant_tool_calls_message(response)
@@ -40,6 +128,10 @@ def extract_tool_name(wrapped) # string name from wrapped tool hash
40
128
 
41
129
  The base `call` loop: add user message → request completion → if tool calls: execute each, add results, loop → else: return content.
42
130
 
131
+ The client is **not** a template method: the base `Assistant` gets it from its provider (`provider.build_client`), lazily, so nothing is built until the first request. As with `Client`, `Provider#create_assistant` injects `provider: self`, and direct instantiation (`Rasti::AI::OpenRouter::Assistant.new`) falls back to the namespace derivation.
132
+
133
+ That leaves `OpenRouter::Assistant` and `HuaweiMaaS::Assistant` with **empty bodies** — like their clients, they survive as the documented public entry point per provider and as the namespace marker `Provider#assistant_class` resolves.
134
+
43
135
  #### Tool — optional base class
44
136
 
45
137
  Simple tool classes only need a `.form` class method and a `#call(params={})` instance method.
@@ -68,22 +160,31 @@ lib/
68
160
  roles.rb
69
161
  client.rb
70
162
  assistant.rb
163
+ provider.rb
71
164
  gemini/
72
165
  roles.rb
73
166
  client.rb
74
167
  assistant.rb
168
+ provider.rb
75
169
  anthropic/
76
170
  roles.rb
77
171
  client.rb
78
172
  assistant.rb
173
+ provider.rb
79
174
  open_router/
80
175
  roles.rb
81
176
  client.rb
82
177
  assistant.rb
178
+ provider.rb
83
179
  huawei_maas/
84
180
  roles.rb
85
181
  client.rb
86
182
  assistant.rb
183
+ provider.rb
184
+ provider.rb # entry point: resolves provider/model, builds client + assistant, generate_text
185
+ provider_aware.rb # shared lazy provider lookup by namespace (Client + Assistant)
186
+ result.rb # normalized generate_text output (content, usage, raw)
187
+ roles.rb # generic roles (user, assistant, system)
87
188
  mcp/
88
189
  server.rb # Rack middleware exposing tools via JSON-RPC 2.0
89
190
  tools_registry.rb # per-request tool registry used by the middleware
@@ -139,6 +240,7 @@ spec/
139
240
  | | OpenAI | Gemini | Anthropic |
140
241
  |---|---|---|---|
141
242
  | Auth | `Authorization: Bearer {key}` | `?key=` query param | `x-api-key: {key}` + `anthropic-version: 2023-06-01` header |
243
+ | Usage payload | `usage.prompt_tokens` / `completion_tokens` | `usageMetadata.promptTokenCount` / `candidatesTokenCount` | `usage.input_tokens` / `output_tokens` |
142
244
  | Endpoint | `POST /chat/completions` | `POST /models/{model}:generateContent` | `POST /messages` |
143
245
  | System prompt | message with `role: system` | top-level `system_instruction` | top-level `system` string |
144
246
  | `max_tokens` | optional | optional | **required** (default 4096 in client) |
@@ -156,7 +258,7 @@ spec/
156
258
 
157
259
  ### OpenAI-compatible providers
158
260
 
159
- OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth. Their clients and assistants inherit from the OpenAI provider and override only `base_url`, `default_api_key`, `default_model`, and `provider_name` (client) / `build_default_client` (assistant). No template methods are reimplemented.
261
+ OpenRouter and Huawei MaaS speak the OpenAI chat completions protocol verbatim — same endpoint shape (`POST /chat/completions`), same request body, same response structure, same tool calling format, same Bearer token auth. They inherit from the OpenAI provider; their whole implementation is `name` / `default_model` / `default_api_key` / `base_url` on the provider. Their clients and assistants are empty subclasses.
160
262
 
161
263
  | | OpenRouter | Huawei MaaS |
162
264
  |---|---|---|
@@ -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 three files under `lib/rasti/ai/<provider>/`:
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 + private helpers
194
- 3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 12 template methods
297
+ 2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method + auth
298
+ 3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 11 template methods
299
+ 4. **`provider.rb`** — inherits `Rasti::AI::Provider`, implements the 7 methods + `THINKING_LEVELS`
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::Assistant` instead of the abstract bases. The assistant only overrides `build_default_client`; the client overrides `base_url`, `default_api_key`, `default_model`, and `provider_name`. No template methods are reimplemented.
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
- The OpenAI client exposes two private hooks that make this possible without duplicating `chat_completions` or `parse_usage`:
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
- - **`default_model`** returns the provider's default model from `Rasti::AI` config
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