Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dd7d13f
confirm rubyllm supports groq via openai-compatible route
mjesar Oct 8, 2026
fb9b552
add dotenv-rails and env example
mjesar Oct 8, 2026
5b442b6
update gemfile.lock for dotenv-rails
mjesar Oct 9, 2026
82d786a
add ruby_llm and note groq setup for 2.x
mjesar Oct 9, 2026
2329dd8
configure ruby_llm for groq in an initializer
mjesar Oct 9, 2026
0ebb634
rename ruby_llm initializer to ruby_llm_groq
mjesar Oct 9, 2026
9e0801f
configure ruby_llm for gemini in an initializer
mjesar Oct 9, 2026
2ea78e4
add supportchat to build a groq chat in one place
mjesar Oct 9, 2026
52c44b7
move chat completions protocol into the groq initializer
mjesar Oct 9, 2026
8ddf5b9
update groq note to use the openai_protocol setting
mjesar Oct 9, 2026
0300da8
replace supportchat with a ruby_llm agent class
mjesar Oct 9, 2026
f7b01e8
add support agent system prompt
mjesar Oct 9, 2026
bc82398
add vcr and webmock for recording llm responses in tests
mjesar Oct 9, 2026
07266cf
configure vcr to record llm calls and hide the groq key
mjesar Oct 9, 2026
6b3d95f
match vcr cassettes on request body
mjesar Oct 9, 2026
fadef85
add lesson 2 doc and link it
mjesar Oct 9, 2026
3fcec2a
add first support agent spec with a recorded groq call
mjesar Oct 9, 2026
82a0e86
record structured output result and testing lessons in lesson 2
mjesar Oct 9, 2026
3bbb86c
record structured output result and testing lessons in lesson 2
mjesar Oct 9, 2026
b7a8681
record structured output result and testing lessons in lesson 2
mjesar Oct 9, 2026
14f5d25
add next step lookup that turns an intent into an action
mjesar Oct 9, 2026
cb3546d
add specs for next step, triage schema and the agent prompt
mjesar Oct 9, 2026
ccd28c4
record groq free tier limits in the plan
mjesar Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copy to .env and fill in real keys. .env is git-ignored.
GROQ_API_KEY=your_key_here
GEMINI_API_KEY=your_key_here
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# Ignore all environment files.
/.env*

# Keep the example file with placeholder keys.
!/.env.example

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
Expand Down
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"

# Load API keys from .env
gem "dotenv-rails", "~> 3.2"

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
Expand All @@ -68,6 +71,9 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
gem "vcr"
gem "webmock"
end

gem "csv"
gem "ruby_llm"
56 changes: 56 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ GEM
xpath (~> 3.2)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.7)
csv (3.3.6)
date (3.5.1)
Expand All @@ -109,6 +112,9 @@ GEM
reline (>= 0.3.8)
diff-lcs (1.6.2)
dotenv (3.2.0)
dotenv-rails (3.2.0)
dotenv (= 3.2.0)
railties (>= 6.1)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.7)
Expand All @@ -122,6 +128,16 @@ GEM
railties (>= 6.1.0)
faker (3.8.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
Expand All @@ -133,6 +149,7 @@ GEM
raabro (~> 1.4)
globalid (1.4.0)
activesupport (>= 6.1)
hashdiff (1.2.1)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
Expand Down Expand Up @@ -184,6 +201,10 @@ GEM
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.5)
multipart-post (2.4.1)
net-http (0.10.0)
net-protocol (>= 0.3.0)
uri (>= 0.11.1)
net-imap (0.6.7)
date
net-protocol
Expand Down Expand Up @@ -286,6 +307,7 @@ GEM
regexp_parser (2.13.1)
reline (0.7.0)
io-console (~> 0.5)
rexml (3.4.4)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
Expand Down Expand Up @@ -335,7 +357,18 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby_llm (2.1.0)
base64
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
json (< 4)
marcel (>= 1.0, < 3)
schematist (~> 1.1)
zeitwerk (~> 2)
rubyzip (3.7.0)
schematist (1.1.0)
securerandom (0.4.1)
selenium-webdriver (4.50.0)
base64 (~> 0.2)
Expand Down Expand Up @@ -391,10 +424,15 @@ GEM
unicode-emoji (4.3.0)
uri (1.1.1)
useragent (0.16.11)
vcr (6.4.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
webmock (3.26.4)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.2)
base64
Expand All @@ -421,6 +459,7 @@ DEPENDENCIES
capybara
csv
debug
dotenv-rails (~> 3.2)
factory_bot_rails
faker
image_processing (~> 1.2)
Expand All @@ -433,6 +472,7 @@ DEPENDENCIES
rails (~> 8.1.4)
rspec-rails
rubocop-rails-omakase
ruby_llm
selenium-webdriver
solid_cable
solid_cache
Expand All @@ -442,7 +482,9 @@ DEPENDENCIES
thruster
turbo-rails
tzinfo-data
vcr
web-console
webmock

CHECKSUMS
action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009
Expand Down Expand Up @@ -470,12 +512,14 @@ CHECKSUMS
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
Expand All @@ -484,6 +528,10 @@ CHECKSUMS
factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89
factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc
faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
Expand All @@ -492,6 +540,7 @@ CHECKSUMS
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
fugit (1.14.0) sha256=e311a9cc85a54bb67649df45060e721282a1e2c8ebad40e41874a3a7d576f37e
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
Expand All @@ -511,6 +560,8 @@ CHECKSUMS
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
msgpack (1.8.5) sha256=97419eaa7b35dfe3e462c0e6c1a3dfdcbfc8756605c09e46e4c6daef127517ba
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
Expand Down Expand Up @@ -555,6 +606,7 @@ CHECKSUMS
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
Expand All @@ -567,7 +619,9 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
ruby_llm (2.1.0) sha256=041e73588758172e37b2883584b3bbca6fbddec2f273cdbdfa5f2b4f2f9a053e
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
schematist (1.1.0) sha256=905d8f0286830926e77e0d631bee325ee741c4b99640bc99735593b40ba73e7b
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.50.0) sha256=d12f94025bc33af44428ed6b8def4896a7bb9c4521212579241650d6cb060fcb
solid_cable (4.1.0) sha256=828e3d509207b82b4f05f4e9c85f696994710679b52d511aa7f97ebd212d3226
Expand All @@ -593,7 +647,9 @@ CHECKSUMS
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6
web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4
webmock (3.26.4) sha256=8d8da206d217ebe6968cfb09c77f4533c23074e1432bad865f3994eacbaad50d
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,5 +190,6 @@ This is a learning project about building a reliable AI agent, not a complete su
- The full plan, with architecture, gems by lesson, data mapping and the evaluation plan: [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md)
- The database tables and how they relate, as a diagram: [docs/DATA_MODEL.md](docs/DATA_MODEL.md)
- Lesson progress, decisions and what broke: [docs/lessons/01-data-model-and-import.md](docs/lessons/01-data-model-and-import.md)
- Lesson 2, the first LLM call: [docs/lessons/02-first-llm-call.md](docs/lessons/02-first-llm-call.md)
- Index of all documentation: [docs/README.md](docs/README.md)
- Where the code lives: this GitHub repo is the source of truth, and pull requests and issues are here. A read-only mirror is kept on [GitLab](https://gitlab.com/mjesar/support_engineer).
14 changes: 14 additions & 0 deletions app/agents/next_step.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class NextStep
STEPS = {
"order_status" => :look_up_order,
"refund" => :check_refund_policy,
"complaint" => :escalate_to_human,
"other" => :answer_directly
}.freeze

# fetch raises on an unknown intent: the schema only allows these four, so
# anything else is a bug to see, not a case to guess a default for.
def self.for(intent)
STEPS.fetch(intent)
end
end
5 changes: 5 additions & 0 deletions app/agents/support_agent.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class SupportAgent < RubyLLM::Agent
# Groq's models are not in RubyLLM's registry, so assume_model_exists is needed.
# The Chat Completions protocol is set in the Groq initializer.
model "openai/gpt-oss-120b", provider: :openai, assume_model_exists: true
end
4 changes: 4 additions & 0 deletions app/agents/triage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class Triage < Schematist::Schema
string :intent, enum: %w[order_status refund complaint other]
string :summary
end
6 changes: 6 additions & 0 deletions app/prompts/support_agent/instructions.txt.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
You are a customer support agent for an online store.

Be concise and polite. Answer in plain language, in a few sentences.
You do not have access to order data yet. If a customer asks about a specific
order, say you cannot look it up and do not guess. Never invent order numbers,
delivery dates or policies.
3 changes: 3 additions & 0 deletions config/initializers/ruby_llm_gemini.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RubyLLM.configure do |config|
config.gemini_api_key = ENV["GEMINI_API_KEY"]
end
6 changes: 6 additions & 0 deletions config/initializers/ruby_llm_groq.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
RubyLLM.configure do |config|
config.openai_api_key = ENV["GROQ_API_KEY"]
config.openai_api_base = "https://api.groq.com/openai/v1"
config.openai_protocol = :chat_completions
config.default_model = "openai/gpt-oss-120b"
end
4 changes: 2 additions & 2 deletions docs/PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ The project choice is locked. New project plans from other tools are not conside
- [ ] Solid Queue + Solid Cache, or Sidekiq + Redis
- [ ] Olist as the base data, or fully generated data only
- [ ] Licenses of the Twitter support dataset checked (Olist done: CC BY-NC-SA 4.0; Bitext done: CDLA-Sharing-1.0, text at https://cdla.dev/sharing-1-0/, use and results are unrestricted, publishing the data itself requires the same license and credit)
- [ ] Groq free-tier limits checked in the Groq console
- [ ] RubyLLM support for Groq confirmed (native or OpenAI-compatible base URL)
- [x] Groq free-tier limits checked (2026-10-09, read from the response headers for `openai/gpt-oss-120b`, not from the console): 1000 requests (the reset time suggests per day) and 8000 tokens per minute. The token limit is the tight one: long prompts, tool results and RAG context in later lessons will hit it, and so will eval runs with many cases
- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also set `config.openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Structured output verified in the console on 2026-10-09: `openai/gpt-oss-120b` returns a hash that matches a Schematist schema through `chat.with_schema(...)` and `reply.parsed`
- [ ] RubyLLM support for Ollama embeddings confirmed
- [ ] Blog platform chosen (DEV.to suggested)

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ lesson pages in order.
| [PROJECT_PLAN.md](PROJECT_PLAN.md) | The whole project: scenario, architecture, stack, tools and permissions, 14 lessons, evaluation plan. |
| [DATA_MODEL.md](DATA_MODEL.md) | The database as a diagram, why each table looks the way it does, and where its data comes from. |
| [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md) | Lesson 1: progress checklist, decisions, what broke and measured results. |
| [lessons/02-first-llm-call.md](lessons/02-first-llm-call.md) | Lesson 2: Groq through RubyLLM 2.x, the agent and its prompt, testing with recorded calls. |

## Conventions

Expand Down
85 changes: 85 additions & 0 deletions docs/lessons/02-first-llm-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Lesson 2: the first LLM call

The app talks to a model for the first time: one agent class, a system prompt in its own file, and a
test that does not call the API on every run.

Last updated: 2026-10-09

## Status

- [x] `ruby_llm` 2.1.0 added, Groq and Gemini configured in initializers
- [x] `SupportAgent` (a `RubyLLM::Agent`) answers through Groq (`openai/gpt-oss-120b`)
- [x] System prompt as a template file
- [x] VCR and WebMock set up, with the Groq key filtered out of recordings
- [x] First agent spec with a recorded cassette
- [x] Structured output tested in the console on the chosen Groq model
- [ ] Tag `lesson-02` on the merge commit of the lesson 2 pull request

## What was built

```
config/initializers/ruby_llm_groq.rb Groq key, URL and protocol (through the OpenAI settings)
config/initializers/ruby_llm_gemini.rb Gemini key
app/agents/support_agent.rb the agent: which model, nothing else
app/prompts/support_agent/instructions.txt.erb the system prompt
spec/support/vcr.rb records LLM calls once, replays them after
```

The agent finds its prompt by name: class `SupportAgent` reads `app/prompts/support_agent/instructions.txt.erb`.
Rename one without the other and the prompt silently stops loading.

## Groq through RubyLLM 2.x

RubyLLM has no Groq provider, so Groq is reached through its OpenAI-compatible API. Three things were
needed, and each one failed first:

- `openai_api_base` points the OpenAI settings at Groq's URL.
- `openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the newer Responses API and Groq
rejects its `include` field (`Field 'include' is not supported`).
- `assume_model_exists: true`, because Groq's model names are not in RubyLLM's registry.

There is only one set of OpenAI settings globally, so real OpenAI could not be used next to Groq without
a separate `RubyLLM.context`. That is not needed yet.

Models retire: `llama-3.3-70b-versatile` is gone from Groq and `gemini-2.5-flash` is closed to new users.
List what a key can use with a call to the provider's `/models` endpoint before picking a model.

## Structured output

Tried in the console on `openai/gpt-oss-120b`, with a schema class and one question:

```ruby
class Triage < Schematist::Schema
string :intent, enum: %w[order_status refund complaint other]
string :summary
end

reply = SupportAgent.new.chat.with_schema(Triage).ask("Where is my package? It was due last week.")
reply.parsed # => {"intent" => "order_status", "summary" => "User is inquiring about ..."}
```

`reply.parsed` only works on a reply that came from a schema. On a plain reply it raises
`JSON::ParserError`, because it tries to read the text as JSON.

The reply is a hash that matches the schema, so the router in a later lesson can rely on a clean intent
instead of parsing free text. This was one question, not a measured result: how often the model picks the
right intent is a job for evals.

## Reading a reply in 2.x

`reply.content` is the text, `reply.model` the model, `reply.tokens.input` and `reply.tokens.output` the
usage, and `reply.finish_reason` why it stopped. These names changed from 1.x (`input_tokens`, `model_id`).

## Testing an LLM call

WebMock blocks real HTTP in specs. VCR lets one real call through, saves it as a cassette in
`spec/cassettes/`, and replays it on later runs, so specs are fast, free and repeatable.

- The Groq key is replaced with `<GROQ_API_KEY>` before anything is saved, so cassettes are safe to commit.
- RubyLLM refuses to build a chat without a key, even when VCR only replays, so `spec/support/vcr.rb`
sets a throwaway key when none is present. Anyone can run the specs from the cassettes without a Groq account.
- VCR ignores the `vcr:` tag on an example unless `configure_rspec_metadata!` is set.
- Requests are matched on method, URL and body, so changing the prompt or the question forces a new
recording instead of replaying an old answer.
- A cassette tests the wiring (config, prompt loading, parsing the reply), not whether the answer is good.
Answer quality is measured with evals in lesson 10. Keep the cassette specs few.
Loading
Loading