ace-llm 0.30.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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/llm/config.yml +31 -0
  3. data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
  4. data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
  5. data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
  6. data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
  7. data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
  8. data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
  9. data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
  10. data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
  11. data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
  12. data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
  13. data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
  14. data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
  15. data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
  16. data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
  17. data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
  18. data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
  19. data/.ace-defaults/llm/providers/anthropic.yml +34 -0
  20. data/.ace-defaults/llm/providers/google.yml +36 -0
  21. data/.ace-defaults/llm/providers/groq.yml +29 -0
  22. data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
  23. data/.ace-defaults/llm/providers/mistral.yml +33 -0
  24. data/.ace-defaults/llm/providers/openai.yml +33 -0
  25. data/.ace-defaults/llm/providers/openrouter.yml +45 -0
  26. data/.ace-defaults/llm/providers/togetherai.yml +26 -0
  27. data/.ace-defaults/llm/providers/xai.yml +30 -0
  28. data/.ace-defaults/llm/providers/zai.yml +18 -0
  29. data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
  30. data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
  31. data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
  32. data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
  33. data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
  34. data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
  35. data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
  36. data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
  37. data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
  38. data/CHANGELOG.md +641 -0
  39. data/LICENSE +21 -0
  40. data/README.md +42 -0
  41. data/Rakefile +14 -0
  42. data/exe/ace-llm +25 -0
  43. data/handbook/guides/llm-query-tool-reference.g.md +683 -0
  44. data/handbook/templates/agent/plan-mode.template.md +48 -0
  45. data/lib/ace/llm/atoms/env_reader.rb +155 -0
  46. data/lib/ace/llm/atoms/error_classifier.rb +200 -0
  47. data/lib/ace/llm/atoms/http_client.rb +162 -0
  48. data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
  49. data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
  50. data/lib/ace/llm/cli/commands/query.rb +280 -0
  51. data/lib/ace/llm/cli.rb +24 -0
  52. data/lib/ace/llm/configuration.rb +180 -0
  53. data/lib/ace/llm/models/fallback_config.rb +216 -0
  54. data/lib/ace/llm/molecules/client_registry.rb +336 -0
  55. data/lib/ace/llm/molecules/config_loader.rb +39 -0
  56. data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
  57. data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
  58. data/lib/ace/llm/molecules/format_handlers.rb +183 -0
  59. data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
  60. data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
  61. data/lib/ace/llm/molecules/preset_loader.rb +99 -0
  62. data/lib/ace/llm/molecules/provider_loader.rb +198 -0
  63. data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
  64. data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
  65. data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
  66. data/lib/ace/llm/organisms/base_client.rb +264 -0
  67. data/lib/ace/llm/organisms/google_client.rb +187 -0
  68. data/lib/ace/llm/organisms/groq_client.rb +197 -0
  69. data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
  70. data/lib/ace/llm/organisms/mistral_client.rb +180 -0
  71. data/lib/ace/llm/organisms/openai_client.rb +195 -0
  72. data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
  73. data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
  74. data/lib/ace/llm/organisms/xai_client.rb +213 -0
  75. data/lib/ace/llm/organisms/zai_client.rb +149 -0
  76. data/lib/ace/llm/query_interface.rb +455 -0
  77. data/lib/ace/llm/version.rb +7 -0
  78. data/lib/ace/llm.rb +61 -0
  79. metadata +318 -0
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Atoms
6
+ # EnvReader provides environment variable reading utilities
7
+ # This is an atom - it has no dependencies on other parts of this gem
8
+ class EnvReader
9
+ # [DEPRECATED] Use Ace::Core.get_env instead
10
+ # Load .env files from .ace cascade
11
+ # @deprecated Use Ace::Core.get_env for individual keys
12
+ # @param set_env [Boolean] Whether to set loaded vars to ENV
13
+ # @return [Hash] All loaded environment variables
14
+ def self.load_env_cascade(set_env: false)
15
+ return {} unless defined?(Ace::Core)
16
+
17
+ warn "[DEPRECATION] EnvReader.load_env_cascade is deprecated. Use Ace::Core.get_env instead" if ENV["DEBUG"]
18
+
19
+ # Delegate to ace-core
20
+ loaded_vars = Ace::Core::Molecules::EnvLoader.load_cascade
21
+
22
+ if set_env
23
+ Ace::Core::Molecules::EnvLoader.set_environment(loaded_vars, overwrite: true)
24
+ end
25
+
26
+ loaded_vars
27
+ end
28
+
29
+ # Get an environment variable value
30
+ # @param key [String] Environment variable name
31
+ # @param default [String, nil] Default value if not found
32
+ # @return [String, nil] The environment variable value or default
33
+ def self.get(key, default = nil)
34
+ ENV.fetch(key, default)
35
+ end
36
+
37
+ # Get an environment variable value, raising if not found
38
+ # @param key [String] Environment variable name
39
+ # @return [String] The environment variable value
40
+ # @raise [KeyError] If the environment variable is not set
41
+ def self.get!(key)
42
+ ENV.fetch(key)
43
+ rescue KeyError
44
+ raise KeyError, "Environment variable '#{key}' is not set"
45
+ end
46
+
47
+ # Check if an environment variable is set
48
+ # @param key [String] Environment variable name
49
+ # @return [Boolean] True if the variable is set, false otherwise
50
+ def self.set?(key)
51
+ ENV.key?(key)
52
+ end
53
+
54
+ # Check if an environment variable is set and not empty
55
+ # @param key [String] Environment variable name
56
+ # @return [Boolean] True if the variable is set and not empty
57
+ def self.present?(key)
58
+ value = ENV[key]
59
+ !value.nil? && !value.strip.empty?
60
+ end
61
+
62
+ # Get multiple environment variables as a hash
63
+ # @param keys [Array<String>] List of environment variable names
64
+ # @param prefix [String, nil] Optional prefix to prepend to each key
65
+ # @return [Hash<String, String>] Hash of key-value pairs (only includes set variables)
66
+ def self.get_multiple(keys, prefix: nil)
67
+ keys.each_with_object({}) do |key, hash|
68
+ full_key = prefix ? "#{prefix}#{key}" : key
69
+ value = ENV[full_key]
70
+ hash[key] = value if value
71
+ end
72
+ end
73
+
74
+ # Get all environment variables matching a pattern
75
+ # @param pattern [Regexp, String] Pattern to match (String will be used as prefix)
76
+ # @return [Hash<String, String>] Matching environment variables
77
+ def self.get_matching(pattern)
78
+ pattern = /^#{Regexp.escape(pattern)}/ if pattern.is_a?(String)
79
+
80
+ ENV.select { |key, _| key =~ pattern }
81
+ end
82
+
83
+ # Temporarily set environment variables for a block
84
+ # @param vars [Hash<String, String>] Variables to set temporarily
85
+ # @yield Block to execute with temporary variables
86
+ # @return [Object] The return value of the block
87
+ def self.with_env(vars)
88
+ original_values = {}
89
+
90
+ # Store original values and set new ones
91
+ vars.each do |key, value|
92
+ original_values[key] = ENV[key]
93
+ if value.nil?
94
+ ENV.delete(key)
95
+ else
96
+ ENV[key] = value.to_s
97
+ end
98
+ end
99
+
100
+ yield
101
+ ensure
102
+ # Restore original values
103
+ original_values.each do |key, value|
104
+ if value.nil?
105
+ ENV.delete(key)
106
+ else
107
+ ENV[key] = value
108
+ end
109
+ end
110
+ end
111
+
112
+ # Get API key for a provider from environment
113
+ # Uses ace-core to check ENV and .ace/.env cascade
114
+ # @param provider [String] Provider name (e.g., "google", "openai")
115
+ # @return [String, nil] API key if found
116
+ def self.get_api_key(provider)
117
+ # Determine which keys to look for based on provider
118
+ key_names = case provider.downcase
119
+ when "google", "gemini"
120
+ ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
121
+ when "groq"
122
+ ["GROQ_API_KEY"]
123
+ when "openai"
124
+ ["OPENAI_API_KEY"]
125
+ when "anthropic"
126
+ ["ANTHROPIC_API_KEY"]
127
+ when "mistral"
128
+ ["MISTRAL_API_KEY"]
129
+ when "together", "togetherai"
130
+ ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"]
131
+ when "lmstudio"
132
+ return nil # No API key needed for local
133
+ when "xai"
134
+ ["XAI_API_KEY"]
135
+ when "openrouter"
136
+ ["OPENROUTER_API_KEY"]
137
+ else
138
+ # Try generic pattern
139
+ ["#{provider.upcase}_API_KEY"]
140
+ end
141
+
142
+ # Use ace-core to check each key (it checks ENV first, then cascade)
143
+ return nil unless defined?(Ace::Core)
144
+
145
+ key_names.each do |key_name|
146
+ value = Ace::Core.get_env(key_name)
147
+ return value if value && !value.strip.empty?
148
+ end
149
+
150
+ nil
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Atoms
6
+ # ErrorClassifier categorizes errors to determine retry and fallback strategies
7
+ # This is an atom - it has no dependencies on other parts of this gem
8
+ class ErrorClassifier
9
+ # Error classification types
10
+ RETRYABLE_WITH_BACKOFF = :retryable_with_backoff
11
+ FALLBACK_IMMEDIATELY = :fallback_immediately
12
+ SKIP_TO_NEXT = :skip_to_next
13
+ TERMINAL = :terminal
14
+
15
+ # Map HTTP status codes to classification
16
+ STATUS_CLASSIFICATIONS = {
17
+ 401 => SKIP_TO_NEXT, # Unauthorized - skip to next provider
18
+ 403 => SKIP_TO_NEXT, # Forbidden - skip to next provider
19
+ 404 => SKIP_TO_NEXT, # Not Found - skip to next provider
20
+ 429 => RETRYABLE_WITH_BACKOFF, # Rate Limited - retry with backoff
21
+ 500 => RETRYABLE_WITH_BACKOFF, # Internal Server Error - retry
22
+ 502 => RETRYABLE_WITH_BACKOFF, # Bad Gateway - retry
23
+ 503 => RETRYABLE_WITH_BACKOFF, # Service Unavailable - retry
24
+ 504 => RETRYABLE_WITH_BACKOFF # Gateway Timeout - retry
25
+ }.freeze
26
+
27
+ QUOTA_LIMIT_PATTERNS = [
28
+ "insufficient_quota",
29
+ "insufficient quota",
30
+ "quota exceeded",
31
+ "quota has been exceeded",
32
+ "quota limit",
33
+ "quota exhausted",
34
+ "out of credit",
35
+ "credits exhausted",
36
+ "insufficient credit",
37
+ "billing hard limit",
38
+ "spending limit",
39
+ "usage limit reached",
40
+ "rate window limit",
41
+ "window limit reached"
42
+ ].freeze
43
+
44
+ # Classify an error for retry/fallback decisions
45
+ # @param error [Exception] The error to classify
46
+ # @return [Symbol] Classification type (RETRYABLE_WITH_BACKOFF, FALLBACK_IMMEDIATELY, SKIP_TO_NEXT, TERMINAL)
47
+ def self.classify(error)
48
+ case error
49
+ when Ace::LLM::AuthenticationError
50
+ SKIP_TO_NEXT
51
+ when Ace::LLM::ProviderError
52
+ # Check if we can extract HTTP status from the error message
53
+ classify_provider_error(error)
54
+ when Faraday::TimeoutError
55
+ FALLBACK_IMMEDIATELY
56
+ when Faraday::ConnectionFailed
57
+ RETRYABLE_WITH_BACKOFF
58
+ when Faraday::ClientError
59
+ classify_faraday_error(error)
60
+ when Faraday::ServerError
61
+ RETRYABLE_WITH_BACKOFF
62
+ else
63
+ TERMINAL
64
+ end
65
+ end
66
+
67
+ # Determine if an error is retryable
68
+ # @param error [Exception] The error to check
69
+ # @return [Boolean] True if error should be retried
70
+ def self.retryable?(error)
71
+ classification = classify(error)
72
+ classification == RETRYABLE_WITH_BACKOFF
73
+ end
74
+
75
+ # Determine if an error should trigger immediate fallback
76
+ # @param error [Exception] The error to check
77
+ # @return [Boolean] True if should fallback immediately
78
+ def self.fallback_immediately?(error)
79
+ classification = classify(error)
80
+ classification == FALLBACK_IMMEDIATELY
81
+ end
82
+
83
+ # Determine if an error should skip to next provider without retry
84
+ # @param error [Exception] The error to check
85
+ # @return [Boolean] True if should skip to next provider
86
+ def self.skip_to_next?(error)
87
+ classification = classify(error)
88
+ classification == SKIP_TO_NEXT
89
+ end
90
+
91
+ # Determine if an error indicates quota/credit/window exhaustion
92
+ # and should move immediately to the next provider.
93
+ # @param error [Exception] The error to check
94
+ # @return [Boolean] True if this is a quota/credit/window-limit condition
95
+ def self.quota_or_credit_limited?(error)
96
+ quota_like_message?(error.message.to_s)
97
+ end
98
+
99
+ # Extract HTTP status code from various error types
100
+ # @param error [Exception] The error
101
+ # @return [Integer, nil] HTTP status code if available
102
+ def self.extract_status_code(error)
103
+ if error.respond_to?(:response) && error.response
104
+ error.response[:status]
105
+ elsif error.respond_to?(:http_status)
106
+ error.http_status
107
+ elsif error.is_a?(Ace::LLM::ProviderError)
108
+ # Try to parse status from error message like "error (503):"
109
+ match = error.message.match(/\((\d{3})\)/)
110
+ match[1].to_i if match
111
+ end
112
+ end
113
+
114
+ # Get retry delay for an error based on retry-after header or default
115
+ # @param error [Exception] The error
116
+ # @param attempt [Integer] Current retry attempt number
117
+ # @param base_delay [Float] Base delay in seconds
118
+ # @return [Float] Delay in seconds with jitter
119
+ def self.retry_delay(error, attempt: 1, base_delay: 1.0)
120
+ # Check for retry-after header (for 429 rate limits)
121
+ if error.respond_to?(:response) && error.response
122
+ headers = error.response[:headers]
123
+ if headers && headers["retry-after"]
124
+ return parse_retry_after(headers["retry-after"])
125
+ end
126
+ end
127
+
128
+ # Exponential backoff with jitter: base_delay * 2^(attempt - 1) * (1 + jitter)
129
+ # Jitter is 10-30% to prevent thundering herd
130
+ exponential_delay = base_delay * (2**(attempt - 1))
131
+ jitter = rand(0.1..0.3)
132
+ exponential_delay * (1 + jitter)
133
+ end
134
+
135
+ # Classify a Faraday::ClientError based on status code
136
+ # @param error [Faraday::ClientError] The error
137
+ # @return [Symbol] Classification type
138
+ def self.classify_faraday_error(error)
139
+ status = extract_status_code(error)
140
+ if status == 429 && quota_like_message?(error.message.to_s)
141
+ return FALLBACK_IMMEDIATELY
142
+ end
143
+
144
+ STATUS_CLASSIFICATIONS.fetch(status, TERMINAL)
145
+ end
146
+ private_class_method :classify_faraday_error
147
+
148
+ # Classify a ProviderError based on its message/attributes
149
+ # @param error [Ace::LLM::ProviderError] The error
150
+ # @return [Symbol] Classification type
151
+ def self.classify_provider_error(error)
152
+ message = error.message.downcase
153
+ return FALLBACK_IMMEDIATELY if quota_like_message?(message)
154
+
155
+ status = extract_status_code(error)
156
+ return STATUS_CLASSIFICATIONS.fetch(status, TERMINAL) if status
157
+
158
+ # Check error message patterns
159
+ if message.include?("timeout")
160
+ FALLBACK_IMMEDIATELY
161
+ elsif message.include?("rate limit")
162
+ RETRYABLE_WITH_BACKOFF
163
+ elsif message.include?("connection failed")
164
+ RETRYABLE_WITH_BACKOFF
165
+ elsif message.include?("unavailable") || message.include?("overloaded")
166
+ RETRYABLE_WITH_BACKOFF
167
+ else
168
+ TERMINAL
169
+ end
170
+ end
171
+ private_class_method :classify_provider_error
172
+
173
+ def self.quota_like_message?(message)
174
+ normalized = message.to_s.downcase
175
+ QUOTA_LIMIT_PATTERNS.any? { |pattern| normalized.include?(pattern) }
176
+ end
177
+ private_class_method :quota_like_message?
178
+
179
+ # Parse retry-after header value
180
+ # @param value [String] Header value (seconds or HTTP date)
181
+ # @return [Float] Delay in seconds
182
+ def self.parse_retry_after(value)
183
+ # Try to parse as integer (seconds)
184
+ Integer(value).to_f
185
+ rescue ArgumentError
186
+ # Try to parse as HTTP date
187
+ begin
188
+ retry_time = Time.httpdate(value)
189
+ delay = retry_time - Time.now
190
+ [delay, 0].max # Don't return negative delays
191
+ rescue ArgumentError
192
+ # Default fallback if parsing fails
193
+ 1.0
194
+ end
195
+ end
196
+ private_class_method :parse_retry_after
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Atoms
8
+ # HTTPClient provides basic HTTP operations using Faraday
9
+ # This is an atom - it has no dependencies on other parts of this gem
10
+ class HTTPClient
11
+ # @param options [Hash] Configuration options
12
+ # @option options [Integer] :timeout (30) Request timeout in seconds
13
+ # @option options [Integer] :open_timeout (10) Connection open timeout in seconds
14
+ # @option options [Integer] :max_retries (3) Maximum number of retries
15
+ # @option options [Array<Integer>] :retry_statuses ([429, 500, 502, 503, 504]) Status codes to retry
16
+ # @option options [Float] :retry_delay (1.0) Initial retry delay in seconds
17
+ def initialize(options = {})
18
+ @timeout = options.fetch(:timeout, 30).to_i
19
+ @open_timeout = options.fetch(:open_timeout, 10).to_i
20
+ @max_retries = options.fetch(:max_retries, 3).to_i
21
+ @retry_statuses = options.fetch(:retry_statuses, [429, 500, 502, 503, 504])
22
+ @retry_delay = options.fetch(:retry_delay, 1.0).to_f
23
+ end
24
+
25
+ # Perform a GET request
26
+ # @param url [String] The URL to request
27
+ # @param options [Hash] Options hash
28
+ # @option options [Hash] :params ({}) Query parameters
29
+ # @option options [Hash] :headers ({}) Request headers
30
+ # @return [Faraday::Response] The response object
31
+ def get(url, **options)
32
+ params = options.fetch(:params, {})
33
+ headers = options.fetch(:headers, {})
34
+
35
+ execute_with_retry do
36
+ connection(url).get do |req|
37
+ req.params = params unless params.empty?
38
+ req.headers.merge!(headers) unless headers.empty?
39
+ end
40
+ end
41
+ end
42
+
43
+ # Perform a POST request
44
+ # @param url [String] The URL to request
45
+ # @param body [String, Hash, nil] Request body (Hash will be JSON-encoded)
46
+ # @param options [Hash] Options hash
47
+ # @option options [Hash] :headers ({}) Request headers
48
+ # @return [Faraday::Response] The response object
49
+ def post(url, body = nil, **options)
50
+ headers = options.fetch(:headers, {})
51
+
52
+ execute_with_retry do
53
+ connection(url).post do |req|
54
+ req.body = body
55
+ req.headers.merge!(headers) unless headers.empty?
56
+ end
57
+ end
58
+ end
59
+
60
+ # Perform a PUT request
61
+ # @param url [String] The URL to request
62
+ # @param body [String, Hash, nil] Request body
63
+ # @param options [Hash] Options hash
64
+ # @option options [Hash] :headers ({}) Request headers
65
+ # @return [Faraday::Response] The response object
66
+ def put(url, body = nil, **options)
67
+ headers = options.fetch(:headers, {})
68
+
69
+ execute_with_retry do
70
+ connection(url).put do |req|
71
+ req.body = body
72
+ req.headers.merge!(headers) unless headers.empty?
73
+ end
74
+ end
75
+ end
76
+
77
+ # Perform a DELETE request
78
+ # @param url [String] The URL to request
79
+ # @param options [Hash] Options hash
80
+ # @option options [Hash] :params ({}) Query parameters
81
+ # @option options [Hash] :headers ({}) Request headers
82
+ # @return [Faraday::Response] The response object
83
+ def delete(url, **options)
84
+ params = options.fetch(:params, {})
85
+ headers = options.fetch(:headers, {})
86
+
87
+ execute_with_retry do
88
+ connection(url).delete do |req|
89
+ req.params = params unless params.empty?
90
+ req.headers.merge!(headers) unless headers.empty?
91
+ end
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Create a Faraday connection for the given URL
98
+ # @param url [String] The base URL
99
+ # @return [Faraday::Connection] Configured connection
100
+ def connection(url)
101
+ Faraday.new(url: url) do |faraday|
102
+ faraday.options.timeout = @timeout
103
+ faraday.options.open_timeout = @open_timeout
104
+
105
+ # Middleware to automatically encode request bodies as JSON
106
+ faraday.request :json
107
+
108
+ # Middleware to automatically parse JSON responses
109
+ # Pattern matches: application/json, application/json; charset=utf-8, text/json, etc.
110
+ faraday.response :json, content_type: /\bjson\b/
111
+
112
+ # Use the default adapter
113
+ faraday.adapter Faraday.default_adapter
114
+ end
115
+ end
116
+
117
+ # Execute a block with retry logic
118
+ # @yield The block to execute
119
+ # @return The result of the block
120
+ def execute_with_retry
121
+ retries = 0
122
+ delay = @retry_delay
123
+
124
+ begin
125
+ yield
126
+ rescue Faraday::Error => e
127
+ if should_retry?(e, retries)
128
+ retries += 1
129
+ sleep(delay)
130
+ delay *= 2 # Exponential backoff
131
+ retry
132
+ else
133
+ raise
134
+ end
135
+ end
136
+ end
137
+
138
+ # Determine if a request should be retried
139
+ # @param error [Faraday::Error] The error that occurred
140
+ # @param retries [Integer] Number of retries so far
141
+ # @return [Boolean] Whether to retry
142
+ def should_retry?(error, retries)
143
+ return false if retries >= @max_retries
144
+
145
+ # Check if it's a timeout error
146
+ return true if error.is_a?(Faraday::TimeoutError)
147
+
148
+ # Check if it's a connection error
149
+ return true if error.is_a?(Faraday::ConnectionFailed)
150
+
151
+ # Check if it's a retriable status code
152
+ if error.respond_to?(:response) && error.response
153
+ status = error.response[:status]
154
+ return @retry_statuses.include?(status)
155
+ end
156
+
157
+ false
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end