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.
- checksums.yaml +7 -0
- data/.ace-defaults/llm/config.yml +31 -0
- data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
- data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
- data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
- data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
- data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
- data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
- data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
- data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
- data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
- data/.ace-defaults/llm/providers/anthropic.yml +34 -0
- data/.ace-defaults/llm/providers/google.yml +36 -0
- data/.ace-defaults/llm/providers/groq.yml +29 -0
- data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
- data/.ace-defaults/llm/providers/mistral.yml +33 -0
- data/.ace-defaults/llm/providers/openai.yml +33 -0
- data/.ace-defaults/llm/providers/openrouter.yml +45 -0
- data/.ace-defaults/llm/providers/togetherai.yml +26 -0
- data/.ace-defaults/llm/providers/xai.yml +30 -0
- data/.ace-defaults/llm/providers/zai.yml +18 -0
- data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
- data/CHANGELOG.md +641 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-llm +25 -0
- data/handbook/guides/llm-query-tool-reference.g.md +683 -0
- data/handbook/templates/agent/plan-mode.template.md +48 -0
- data/lib/ace/llm/atoms/env_reader.rb +155 -0
- data/lib/ace/llm/atoms/error_classifier.rb +200 -0
- data/lib/ace/llm/atoms/http_client.rb +162 -0
- data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
- data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
- data/lib/ace/llm/cli/commands/query.rb +280 -0
- data/lib/ace/llm/cli.rb +24 -0
- data/lib/ace/llm/configuration.rb +180 -0
- data/lib/ace/llm/models/fallback_config.rb +216 -0
- data/lib/ace/llm/molecules/client_registry.rb +336 -0
- data/lib/ace/llm/molecules/config_loader.rb +39 -0
- data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
- data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
- data/lib/ace/llm/molecules/format_handlers.rb +183 -0
- data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
- data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
- data/lib/ace/llm/molecules/preset_loader.rb +99 -0
- data/lib/ace/llm/molecules/provider_loader.rb +198 -0
- data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
- data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
- data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
- data/lib/ace/llm/organisms/base_client.rb +264 -0
- data/lib/ace/llm/organisms/google_client.rb +187 -0
- data/lib/ace/llm/organisms/groq_client.rb +197 -0
- data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
- data/lib/ace/llm/organisms/mistral_client.rb +180 -0
- data/lib/ace/llm/organisms/openai_client.rb +195 -0
- data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
- data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
- data/lib/ace/llm/organisms/xai_client.rb +213 -0
- data/lib/ace/llm/organisms/zai_client.rb +149 -0
- data/lib/ace/llm/query_interface.rb +455 -0
- data/lib/ace/llm/version.rb +7 -0
- data/lib/ace/llm.rb +61 -0
- 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
|