Skip to content

Commit 9677067

Browse files
danielstpaulclaude
andcommitted
v0.6.0: install generator, auto-loading engine under Rails, release workflow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2aa9b8d commit 9677067

8 files changed

Lines changed: 176 additions & 1 deletion

File tree

.github/workflows/release.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Release
2+
3+
# Publishes to RubyGems via Trusted Publishing (OIDC — no API key stored).
4+
# One-time setup on rubygems.org (gem owner, in the browser):
5+
# Profile → Trusted publishers → add publisher for gem "ruby_llm-resilience"
6+
# with repository danielstpaul/ruby_llm-resilience and workflow release.yml.
7+
# Then: create a GitHub Release (or run this manually) and it ships.
8+
9+
on:
10+
release:
11+
types: [ published ]
12+
workflow_dispatch:
13+
14+
permissions:
15+
contents: write
16+
id-token: write
17+
18+
jobs:
19+
push:
20+
runs-on: ubuntu-latest
21+
environment: release
22+
steps:
23+
- uses: actions/checkout@v4
24+
- uses: ruby/setup-ruby@v1
25+
with:
26+
ruby-version: "3.4"
27+
bundler-cache: true
28+
- run: bundle exec rspec
29+
- uses: rubygems/release-gem@v1

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## [Unreleased]
44

5+
## [0.6.0] - 2026-07-14
6+
7+
Rails DX:
8+
9+
- `rails g resilience:install` — writes a fully-commented initializer
10+
covering every configuration seam
11+
- The dashboard engine now loads automatically under Rails (no Gemfile
12+
`require:` needed; mounting stays opt-in). Non-Rails usage unchanged —
13+
the core still has zero dependencies
14+
- RubyGems Trusted Publishing release workflow (.github/workflows/release.yml)
15+
516
## [0.5.0] - 2026-07-14
617

718
- Dashboard table: click-to-sort columns (text/numeric aware) and a

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ outage.
3535
gem "ruby_llm-resilience"
3636
```
3737

38+
```bash
39+
rails g resilience:install # writes a fully-commented initializer
40+
```
41+
42+
Outside Rails, or by hand:
43+
3844
```ruby
3945
# config/initializers/resilience.rb
4046
RubyLLM::Resilience.configure do |c|
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
require "rails/generators"
4+
5+
module RubyLLM
6+
module Resilience
7+
# rails g resilience:install
8+
#
9+
# Writes a fully-commented initializer covering every configuration seam.
10+
# (Namespaced inside RubyLLM::Resilience — the top-level ::Resilience
11+
# constant is reserved for the opt-in shorthand alias.)
12+
class InstallGenerator < Rails::Generators::Base
13+
namespace "resilience:install"
14+
source_root File.expand_path("templates", __dir__)
15+
16+
desc "Creates config/initializers/resilience.rb with a commented example configuration"
17+
18+
def create_initializer
19+
template "initializer.rb", "config/initializers/resilience.rb"
20+
end
21+
22+
def show_next_steps
23+
say <<~NEXT, :green
24+
25+
ruby_llm-resilience installed.
26+
27+
Next steps:
28+
1. Review config/initializers/resilience.rb — the defaults work,
29+
but multi-process apps should configure a shared cache_store.
30+
2. Optional dashboard: mount it behind YOUR auth in config/routes.rb:
31+
mount RubyLLM::Resilience::Engine => "/resilience"
32+
(it 404s everything until dashboard_auth is configured — see the
33+
initializer.)
34+
3. Guard a call:
35+
RubyLLM::Resilience.run("api:openai:embeddings") { RubyLLM.embed(text) }
36+
NEXT
37+
end
38+
end
39+
end
40+
end
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
# ruby_llm-resilience — circuit breakers and fallback chains for LLM calls.
4+
# Docs: https://github.com/danielstpaul/ruby_llm-resilience
5+
#
6+
# Everything below is optional; the gem works with defaults out of the box
7+
# (per-process memory store, threshold 5, cooldown 120s). The two settings
8+
# most apps DO want are cache_store (multi-process correctness) and
9+
# fallback_models (the actual routing).
10+
RubyLLM::Resilience.configure do |config|
11+
# --- Store -----------------------------------------------------------
12+
# REQUIRED for multi-process apps: the default MemoryStore is per-process,
13+
# so a breaker tripped in one worker stays closed in the others. Any store
14+
# with read / write(expires_in:, unless_exist:) / increment(expires_in:) /
15+
# delete / delete_multi works — Redis is the usual choice:
16+
#
17+
# config.cache_store = ActiveSupport::Cache::RedisCacheStore.new(
18+
# url: ENV["REDIS_URL"], namespace: "circuit_breaker"
19+
# )
20+
21+
# --- Breaker knobs ----------------------------------------------------
22+
# config.failure_threshold = 5 # consecutive failures before trip
23+
# config.cooldown_seconds = 120 # open duration before a probe
24+
# config.failures_window_seconds = 3600 # failure-counter window
25+
#
26+
# Per-service overrides (a fast-recovery moderation endpoint shouldn't
27+
# share a cooldown with an expensive batch endpoint):
28+
# config.services = {
29+
# "api:openai:moderation" => { failure_threshold: 2, cooldown_seconds: 30 }
30+
# }
31+
32+
# --- Fallback routing -------------------------------------------------
33+
# One deliberate tier-hop per model is the recommended shape. Values may
34+
# be a single model or an array of hops.
35+
# config.fallback_models = {
36+
# "claude-haiku-4-5" => "claude-sonnet-4-6",
37+
# "claude-sonnet-4-6" => "claude-opus-4-7",
38+
# "gemini-3.5-flash" => "claude-sonnet-4-6" # cross-provider safety net
39+
# }
40+
41+
# --- Telemetry (alert on the error, graph the gauge, trend the counter) —
42+
# config.on_error = ->(error, context) {
43+
# Rails.error.report(error, handled: true, context: context)
44+
# }
45+
# config.on_status = ->(service, state) {
46+
# Appsignal.set_gauge("circuit_breaker.state", state == :open ? 1 : 0, service: service)
47+
# }
48+
# config.on_fallback = ->(from:, to:, error:) {
49+
# Appsignal.increment_counter("llm.fallback", 1,
50+
# from: from[:service], to: to[:service], error: error.class.name)
51+
# }
52+
53+
# --- Dashboard (mount RubyLLM::Resilience::Engine in routes.rb) --------
54+
# DENY-BY-DEFAULT: every dashboard request 404s until you configure this.
55+
# config.dashboard_auth = ->(controller) {
56+
# controller.head :not_found unless controller.respond_to?(:current_user) &&
57+
# controller.current_user&.admin?
58+
# }
59+
#
60+
# Show a static fleet (instead of only breakers seen since boot), with
61+
# descriptions for the About column:
62+
# config.dashboard_services = %w[api:anthropic:sonnet api:openai:moderation]
63+
# config.service_metadata = {
64+
# "api:anthropic:sonnet" => { description: "Coaching", consumers: "Chat" }
65+
# }
66+
end

lib/ruby_llm/resilience.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
require_relative "resilience/breaker"
99
require_relative "resilience/chain"
1010

11+
# Under Rails the dashboard engine loads automatically (mounting it stays
12+
# opt-in via routes). Outside Rails the core works alone — zero dependencies.
13+
require_relative "resilience/engine" if defined?(::Rails::Engine)
14+
1115
module RubyLLM
1216
# Circuit breakers and fallback chains for LLM apps.
1317
#

lib/ruby_llm/resilience/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
module RubyLLM
44
module Resilience
5-
VERSION = "0.5.0"
5+
VERSION = "0.6.0"
66
end
77
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
require "rails/generators"
4+
require "generators/resilience/install_generator"
5+
require "tmpdir"
6+
7+
RSpec.describe RubyLLM::Resilience::InstallGenerator do
8+
it "writes the commented initializer into the destination root" do
9+
dest = File.join(Dir.mktmpdir, "app")
10+
described_class.start([], destination_root: dest)
11+
12+
initializer = File.join(dest, "config/initializers/resilience.rb")
13+
expect(File).to exist(initializer)
14+
content = File.read(initializer)
15+
expect(content).to include("RubyLLM::Resilience.configure")
16+
expect(content).to include("fallback_models")
17+
expect(content).to include("dashboard_auth")
18+
end
19+
end

0 commit comments

Comments
 (0)