ruby_llm-resilience 0.6.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.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in top-level alias:
4
+ #
5
+ # require "ruby_llm/resilience/shorthand"
6
+ # Resilience.run("api:openai:embeddings") { ... }
7
+ #
8
+ # Kept out of the default require deliberately — claiming a top-level
9
+ # ::Resilience constant is namespace pollution unless you ask for it.
10
+ Resilience = RubyLLM::Resilience unless defined?(::Resilience)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ # Default service_namer: maps a model name to a per-provider, per-TIER
6
+ # breaker name ("api:anthropic:sonnet").
7
+ #
8
+ # Tier-level (not per-model) breakers are deliberate: provider capacity
9
+ # incidents are tier-correlated (when Sonnet is overloaded, every Sonnet
10
+ # version typically is), and a version rollover (sonnet-4-5 → sonnet-4-6)
11
+ # shouldn't fragment health state across two breakers.
12
+ #
13
+ # Custom naming is one lambda away:
14
+ # config.service_namer = ->(model) { "api:#{model}" } # per-model
15
+ module TierNamer
16
+ module_function
17
+
18
+ def call(model_name)
19
+ provider = Resilience.config.provider_resolver.call(model_name).to_s
20
+
21
+ case provider
22
+ when "anthropic" then anthropic(model_name)
23
+ when "gemini", "google" then google(model_name)
24
+ when "openai" then openai(model_name)
25
+ else "api:unknown:other"
26
+ end
27
+ end
28
+
29
+ def anthropic(model_name)
30
+ case model_name.to_s
31
+ when /haiku/ then "api:anthropic:haiku"
32
+ when /sonnet/ then "api:anthropic:sonnet"
33
+ when /opus/ then "api:anthropic:opus"
34
+ else "api:anthropic:other"
35
+ end
36
+ end
37
+
38
+ # Order matters — flash-lite must be tested before flash, since
39
+ # "gemini-3.1-flash-lite" matches both /flash/ and /flash-lite/.
40
+ def google(model_name)
41
+ case model_name.to_s
42
+ when /flash-lite/ then "api:google:flash-lite"
43
+ when /flash/ then "api:google:flash"
44
+ when /pro/ then "api:google:pro"
45
+ else "api:google:other"
46
+ end
47
+ end
48
+
49
+ # Order matters — `-nano` / `-mini` / `-pro` must be tested before the
50
+ # bare `^gpt` match (gpt-5.5-pro matches both `-pro` and `^gpt`).
51
+ def openai(model_name)
52
+ case model_name.to_s
53
+ when /-nano/ then "api:openai:gpt-nano"
54
+ when /-mini/ then "api:openai:gpt-mini"
55
+ when /-pro/ then "api:openai:gpt-pro"
56
+ when /^gpt/ then "api:openai:gpt"
57
+ else "api:openai:other"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Resilience
5
+ VERSION = "0.6.0"
6
+ end
7
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resilience/version"
4
+ require_relative "resilience/errors"
5
+ require_relative "resilience/memory_store"
6
+ require_relative "resilience/tier_namer"
7
+ require_relative "resilience/configuration"
8
+ require_relative "resilience/breaker"
9
+ require_relative "resilience/chain"
10
+
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
+
15
+ module RubyLLM
16
+ # Circuit breakers and fallback chains for LLM apps.
17
+ #
18
+ # RubyLLM::Resilience.configure do |c|
19
+ # c.cache_store = ActiveSupport::Cache::RedisCacheStore.new(...)
20
+ # c.fallback_models = { "claude-haiku-4-5" => "claude-sonnet-4-6" }
21
+ # c.on_error = ->(error, ctx) { Rails.error.report(error, context: ctx) }
22
+ # c.on_status = ->(service, state) { Appsignal.set_gauge("circuit_breaker.state", state == :open ? 1 : 0, service:) }
23
+ # end
24
+ #
25
+ # RubyLLM::Resilience.run("api:openai:embeddings") { RubyLLM.embed(text) }
26
+ module Resilience
27
+ class << self
28
+ def config
29
+ @config ||= Configuration.new
30
+ end
31
+
32
+ # Configure once at boot; the configuration freezes afterwards so
33
+ # fiber/thread safety comes from immutability. Use
34
+ # reset_configuration! in tests.
35
+ def configure
36
+ @config ||= Configuration.new
37
+ yield @config
38
+ @config.freeze
39
+ @config
40
+ end
41
+
42
+ def reset_configuration!
43
+ @config = Configuration.new
44
+ Breaker.reset_registry!
45
+ @config
46
+ end
47
+
48
+ def run(service, &block)
49
+ Chain.run(service, &block)
50
+ end
51
+
52
+ def run_with_model_fallback(primary_model, fallback: :map, &block)
53
+ Chain.run_with_model_fallback(primary_model, fallback: fallback, &block)
54
+ end
55
+
56
+ def run_with_fallback(*steps)
57
+ Chain.run_with_fallback(*steps)
58
+ end
59
+
60
+ def trippable?(error)
61
+ config.resolved_trippable_errors.any? { |klass| error.is_a?(klass) }
62
+ end
63
+
64
+ # Convenience: the breaker name the configured service_namer would
65
+ # assign to a model.
66
+ def service_for(model_name)
67
+ config.service_namer.call(model_name)
68
+ end
69
+
70
+ # Fallback routing grouped by breaker service, for dashboards:
71
+ # { "api:anthropic:haiku" => ["claude-haiku-4-5 → claude-sonnet-4-6"], ... }
72
+ # A service can carry several routes when multiple models map into the
73
+ # same tier breaker.
74
+ def fallback_routes
75
+ config.fallback_models.keys.each_with_object(Hash.new { |h, k| h[k] = [] }) do |model, routes|
76
+ service = service_for(model)
77
+ chain = [ model, *config.fallbacks_for(model) ]
78
+ routes[service] << chain.join(" → ")
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ruby_llm/resilience"
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-resilience
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel St Paul
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.13'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.13'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec-rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ description: 'RubyLLM gives you every provider. This gives you what happens when one
69
+ of them goes down: tier-aware circuit breakers, cross-provider fallback chains,
70
+ and fail-open store semantics — battle-tested behind 450k+ production LLM calls.'
71
+ email:
72
+ - danielstpaul@hotmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE.txt
79
+ - README.md
80
+ - app/controllers/ruby_llm/resilience/breakers_controller.rb
81
+ - app/views/layouts/ruby_llm/resilience/application.html.erb
82
+ - app/views/ruby_llm/resilience/breakers/index.html.erb
83
+ - config/routes.rb
84
+ - lib/generators/resilience/install_generator.rb
85
+ - lib/generators/resilience/templates/initializer.rb
86
+ - lib/ruby_llm-resilience.rb
87
+ - lib/ruby_llm/resilience.rb
88
+ - lib/ruby_llm/resilience/breaker.rb
89
+ - lib/ruby_llm/resilience/chain.rb
90
+ - lib/ruby_llm/resilience/configuration.rb
91
+ - lib/ruby_llm/resilience/engine.rb
92
+ - lib/ruby_llm/resilience/errors.rb
93
+ - lib/ruby_llm/resilience/memory_store.rb
94
+ - lib/ruby_llm/resilience/shorthand.rb
95
+ - lib/ruby_llm/resilience/tier_namer.rb
96
+ - lib/ruby_llm/resilience/version.rb
97
+ homepage: https://github.com/danielstpaul/ruby_llm-resilience
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ homepage_uri: https://github.com/danielstpaul/ruby_llm-resilience
102
+ source_code_uri: https://github.com/danielstpaul/ruby_llm-resilience
103
+ changelog_uri: https://github.com/danielstpaul/ruby_llm-resilience/blob/main/CHANGELOG.md
104
+ rubygems_mfa_required: 'true'
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '3.2'
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubygems_version: 3.6.9
120
+ specification_version: 4
121
+ summary: Circuit breakers and fallback chains for LLM apps.
122
+ test_files: []