active_harness 0.2.6 → 0.2.8
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 +4 -4
- data/lib/active_harness/agent/models.rb +9 -2
- data/lib/active_harness/agent/providers.rb +27 -9
- data/lib/active_harness/agent/ruby_llm_backend.rb +90 -0
- data/lib/active_harness/agent.rb +8 -1
- data/lib/active_harness/configuration.rb +173 -0
- data/lib/active_harness/http/retry_policy.rb +47 -0
- data/lib/active_harness/providers/anthropic.rb +3 -4
- data/lib/active_harness/providers/azure.rb +97 -0
- data/lib/active_harness/providers/base.rb +4 -0
- data/lib/active_harness/providers/bedrock.rb +29 -0
- data/lib/active_harness/providers/custom.rb +96 -0
- data/lib/active_harness/providers/deepseek.rb +62 -0
- data/lib/active_harness/providers/gemini.rb +3 -5
- data/lib/active_harness/providers/gpustack.rb +78 -0
- data/lib/active_harness/providers/groq.rb +3 -5
- data/lib/active_harness/providers/mistral.rb +63 -0
- data/lib/active_harness/providers/ollama.rb +67 -0
- data/lib/active_harness/providers/openai.rb +3 -5
- data/lib/active_harness/providers/openrouter.rb +5 -7
- data/lib/active_harness/providers/perplexity.rb +62 -0
- data/lib/active_harness/providers/vertexai.rb +34 -0
- data/lib/active_harness/providers/xai.rb +62 -0
- data/lib/active_harness.rb +35 -0
- data/lib/generators/active_harness/install/install_generator.rb +16 -0
- data/lib/generators/active_harness/install/templates/initializers/active_harness.rb +101 -0
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7139860d4aa461b1d86eca144c4929d4bf362192099498eadf61401b85acf8d
|
|
4
|
+
data.tar.gz: 3f00f021d6fd660748767349fb6a00ba9187c6baf507d18b01ac668c52c67904
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c335d3ecce967dc10424ef0cbe15922561627539e7cbf5eb2a34ad7e672c964bd7fd796ed129229a41a7f32992c22241353e16b8e314aec7cd71878ea86148d
|
|
7
|
+
data.tar.gz: 930e5ad7ad85e5e269772e90c45453fb6672dc2f8752c3d546f9888d3d2c8cd2020a24190f7391f8217bf1c8e24cde67945788870c3b1d1ec60e01747c0d2698
|
|
@@ -134,8 +134,15 @@ module ActiveHarness
|
|
|
134
134
|
@models = []
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
def use(provider:, model:, temperature: nil)
|
|
138
|
-
@models << {
|
|
137
|
+
def use(provider:, model:, temperature: nil, name: nil, retry_attempts: nil, retry_delay: nil)
|
|
138
|
+
@models << {
|
|
139
|
+
provider: provider,
|
|
140
|
+
model: model,
|
|
141
|
+
temperature: temperature,
|
|
142
|
+
name: name,
|
|
143
|
+
retry_attempts: retry_attempts,
|
|
144
|
+
retry_delay: retry_delay
|
|
145
|
+
}.compact
|
|
139
146
|
end
|
|
140
147
|
|
|
141
148
|
alias fallback use
|
|
@@ -1,36 +1,54 @@
|
|
|
1
1
|
module ActiveHarness
|
|
2
2
|
class Agent
|
|
3
|
-
# Errors that allow retrying the next model in the chain
|
|
3
|
+
# Errors that allow retrying the next model in the chain.
|
|
4
|
+
# InvalidRequestError is included here so that a bad model name (or any
|
|
5
|
+
# per-model request failure) does not abort the entire chain — the next
|
|
6
|
+
# fallback model will be attempted instead.
|
|
4
7
|
RETRYABLE_ERRORS = [
|
|
5
8
|
Errors::TimeoutError,
|
|
6
9
|
Errors::RateLimitError,
|
|
7
10
|
Errors::ServerError,
|
|
8
|
-
Errors::ProviderUnavailableError
|
|
11
|
+
Errors::ProviderUnavailableError,
|
|
12
|
+
Errors::InvalidRequestError
|
|
9
13
|
].freeze
|
|
10
14
|
|
|
11
|
-
# Errors that abort the entire chain immediately
|
|
15
|
+
# Errors that abort the entire chain immediately.
|
|
16
|
+
# InvalidApiKeyError — the key is wrong for every model, retrying is pointless.
|
|
17
|
+
# SafetyBlockedError — the input itself is blocked; a different model won't help.
|
|
12
18
|
STOP_ERRORS = [
|
|
13
|
-
Errors::InvalidRequestError,
|
|
14
19
|
Errors::InvalidApiKeyError,
|
|
15
20
|
Errors::SafetyBlockedError
|
|
16
21
|
].freeze
|
|
17
22
|
|
|
18
23
|
PROVIDERS = {
|
|
19
|
-
openai:
|
|
20
|
-
openrouter:
|
|
21
|
-
groq:
|
|
22
|
-
gemini:
|
|
23
|
-
anthropic:
|
|
24
|
+
openai: -> { Providers::OpenAI.new },
|
|
25
|
+
openrouter: -> { Providers::OpenRouter.new },
|
|
26
|
+
groq: -> { Providers::Groq.new },
|
|
27
|
+
gemini: -> { Providers::Gemini.new },
|
|
28
|
+
anthropic: -> { Providers::Anthropic.new },
|
|
29
|
+
xai: -> { Providers::XAI.new },
|
|
30
|
+
deepseek: -> { Providers::DeepSeek.new },
|
|
31
|
+
mistral: -> { Providers::Mistral.new },
|
|
32
|
+
ollama: -> { Providers::Ollama.new },
|
|
33
|
+
perplexity: -> { Providers::Perplexity.new },
|
|
34
|
+
gpustack: -> { Providers::GPUStack.new },
|
|
35
|
+
azure: -> { Providers::Azure.new },
|
|
36
|
+
bedrock: -> { Providers::Bedrock.new },
|
|
37
|
+
vertexai: -> { Providers::VertexAI.new },
|
|
38
|
+
custom: -> { Providers::Custom.new }
|
|
24
39
|
}.freeze
|
|
25
40
|
|
|
26
41
|
private
|
|
27
42
|
|
|
28
43
|
def attempt_model(entry, system_prompt)
|
|
44
|
+
return attempt_via_ruby_llm(entry, system_prompt) if @config[:ruby_llm_backend]
|
|
45
|
+
|
|
29
46
|
provider = resolve_provider(entry[:provider])
|
|
30
47
|
messages = build_messages(system_prompt, @input)
|
|
31
48
|
opts = { model: entry[:model], messages: messages }
|
|
32
49
|
opts[:temperature] = entry[:temperature] if entry[:temperature]
|
|
33
50
|
opts[:stream] = @stream if @stream
|
|
51
|
+
opts[:name] = entry[:name] if entry[:name]
|
|
34
52
|
provider.call(**opts)
|
|
35
53
|
end
|
|
36
54
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module ActiveHarness
|
|
2
|
+
class Agent
|
|
3
|
+
# -------------------------------------------------------------------------
|
|
4
|
+
# RubyLLM backend DSL
|
|
5
|
+
#
|
|
6
|
+
# Allows an agent to delegate HTTP calls to the `ruby_llm` gem instead of
|
|
7
|
+
# ActiveHarness's built-in Net::HTTP providers.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
#
|
|
11
|
+
# ruby_llm_backend do |params|
|
|
12
|
+
# RubyLLM.chat(
|
|
13
|
+
# model: params.model,
|
|
14
|
+
# provider: params.provider,
|
|
15
|
+
# assume_model_exists: true
|
|
16
|
+
# ).tap { |c| c.with_temperature(params.temperature) if params.temperature }
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# The block receives a BackendParams struct and must return a RubyLLM::Chat.
|
|
20
|
+
# ActiveHarness calls chat.ask(@input) and maps the result to its Result format.
|
|
21
|
+
#
|
|
22
|
+
# All existing features work unchanged:
|
|
23
|
+
# - model do / use / fallback (order of attempts)
|
|
24
|
+
# - retry_attempts / retry_delay (per-model retry policy)
|
|
25
|
+
# - fallback chain on error
|
|
26
|
+
# - hooks (:setup, :before_call, :retry, :failure, …)
|
|
27
|
+
# - streaming via stream: lambda
|
|
28
|
+
# -------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# Passed to the ruby_llm_backend block for each model attempt.
|
|
31
|
+
BackendParams = Struct.new(:model, :provider, :temperature, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Define the RubyLLM backend block for this agent class.
|
|
35
|
+
def ruby_llm_backend(&block)
|
|
36
|
+
agent_config[:ruby_llm_backend] = block
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Called from attempt_model when ruby_llm_backend is configured.
|
|
43
|
+
def attempt_via_ruby_llm(entry, system_prompt)
|
|
44
|
+
require "ruby_llm"
|
|
45
|
+
|
|
46
|
+
backend = @config[:ruby_llm_backend]
|
|
47
|
+
|
|
48
|
+
params = BackendParams.new(
|
|
49
|
+
model: entry[:model],
|
|
50
|
+
provider: entry[:provider].to_s,
|
|
51
|
+
temperature: entry[:temperature]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
chat = backend.call(params)
|
|
55
|
+
chat.with_instructions(system_prompt) if system_prompt
|
|
56
|
+
|
|
57
|
+
if @stream
|
|
58
|
+
response = chat.ask(@input) { |chunk| @stream.call(chunk.content) if chunk.content }
|
|
59
|
+
else
|
|
60
|
+
response = chat.ask(@input)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
{ content: response.content, usage: ruby_llm_usage(response) }
|
|
64
|
+
rescue ::RubyLLM::UnauthorizedError => e
|
|
65
|
+
raise Errors::InvalidApiKeyError, e.message
|
|
66
|
+
rescue ::RubyLLM::RateLimitError, ::RubyLLM::OverloadedError => e
|
|
67
|
+
raise Errors::RateLimitError, e.message
|
|
68
|
+
rescue ::RubyLLM::ServerError, ::RubyLLM::ServiceUnavailableError => e
|
|
69
|
+
raise Errors::ServerError, e.message
|
|
70
|
+
rescue ::RubyLLM::BadRequestError, ::RubyLLM::ContextLengthExceededError => e
|
|
71
|
+
raise Errors::InvalidRequestError, e.message
|
|
72
|
+
rescue ::RubyLLM::Error => e
|
|
73
|
+
raise Errors::ProviderError, e.message
|
|
74
|
+
rescue LoadError
|
|
75
|
+
raise Errors::ProviderUnavailableError,
|
|
76
|
+
"The `ruby_llm` gem is required. Add `gem \"ruby_llm\"` to your Gemfile."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ruby_llm_usage(response)
|
|
80
|
+
t = response.tokens
|
|
81
|
+
return nil unless t
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
input_tokens: t.input,
|
|
85
|
+
output_tokens: t.output,
|
|
86
|
+
total_tokens: (t.input.to_i + t.output.to_i)
|
|
87
|
+
}.compact
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/active_harness/agent.rb
CHANGED
|
@@ -61,9 +61,15 @@ module ActiveHarness
|
|
|
61
61
|
run_hook(:before_call)
|
|
62
62
|
attempts = []
|
|
63
63
|
|
|
64
|
+
cfg = ActiveHarness.config
|
|
65
|
+
|
|
64
66
|
model_list.each do |entry|
|
|
67
|
+
retry_policy = Http::RetryPolicy.new(
|
|
68
|
+
max_attempts: entry[:retry_attempts] || cfg.retry_default_attempts,
|
|
69
|
+
base_delay: entry[:retry_delay] || cfg.retry_default_delay
|
|
70
|
+
)
|
|
65
71
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
-
response = attempt_model(entry, @system_prompt)
|
|
72
|
+
response = retry_policy.run { attempt_model(entry, @system_prompt) }
|
|
67
73
|
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
|
|
68
74
|
result = build_result(response, entry, attempts, elapsed)
|
|
69
75
|
save_to_memory(result)
|
|
@@ -139,4 +145,5 @@ require_relative "agent/hooks"
|
|
|
139
145
|
require_relative "agent/models"
|
|
140
146
|
require_relative "agent/providers"
|
|
141
147
|
require_relative "agent/output_parser"
|
|
148
|
+
require_relative "agent/ruby_llm_backend"
|
|
142
149
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
module ActiveHarness
|
|
2
|
+
# Central configuration object.
|
|
3
|
+
#
|
|
4
|
+
# Usage in config/initializers/active_harness.rb:
|
|
5
|
+
#
|
|
6
|
+
# ActiveHarness.configure do |config|
|
|
7
|
+
# config.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
8
|
+
# config.openai_api_url = "https://api.openai.com/v1/chat/completions"
|
|
9
|
+
# # ...
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# If a value is not explicitly set, it is read from the corresponding
|
|
13
|
+
# environment variable so all existing ENV-based setups keep working.
|
|
14
|
+
class Configuration
|
|
15
|
+
# -------------------------------------------------------------------------
|
|
16
|
+
# Global
|
|
17
|
+
# -------------------------------------------------------------------------
|
|
18
|
+
attr_accessor :request_timeout
|
|
19
|
+
|
|
20
|
+
# Retry policy for a single model (exponential backoff).
|
|
21
|
+
# Set retry_default_attempts to 1 to disable retries entirely.
|
|
22
|
+
# Per-model values can be set via retry_attempts: / retry_delay: in the DSL.
|
|
23
|
+
attr_accessor :retry_default_attempts
|
|
24
|
+
attr_accessor :retry_default_delay
|
|
25
|
+
|
|
26
|
+
# -------------------------------------------------------------------------
|
|
27
|
+
# OpenAI
|
|
28
|
+
# -------------------------------------------------------------------------
|
|
29
|
+
attr_accessor :openai_api_key
|
|
30
|
+
attr_accessor :openai_api_url
|
|
31
|
+
|
|
32
|
+
# -------------------------------------------------------------------------
|
|
33
|
+
# Anthropic
|
|
34
|
+
# -------------------------------------------------------------------------
|
|
35
|
+
attr_accessor :anthropic_api_key
|
|
36
|
+
attr_accessor :anthropic_api_url
|
|
37
|
+
|
|
38
|
+
# -------------------------------------------------------------------------
|
|
39
|
+
# Google Gemini (OpenAI-compatible REST endpoint)
|
|
40
|
+
# -------------------------------------------------------------------------
|
|
41
|
+
attr_accessor :gemini_api_key
|
|
42
|
+
attr_accessor :gemini_api_url
|
|
43
|
+
|
|
44
|
+
# -------------------------------------------------------------------------
|
|
45
|
+
# Groq
|
|
46
|
+
# -------------------------------------------------------------------------
|
|
47
|
+
attr_accessor :groq_api_key
|
|
48
|
+
attr_accessor :groq_api_url
|
|
49
|
+
|
|
50
|
+
# -------------------------------------------------------------------------
|
|
51
|
+
# OpenRouter
|
|
52
|
+
# -------------------------------------------------------------------------
|
|
53
|
+
attr_accessor :openrouter_api_key
|
|
54
|
+
attr_accessor :openrouter_api_url
|
|
55
|
+
attr_accessor :openrouter_http_referer
|
|
56
|
+
|
|
57
|
+
# -------------------------------------------------------------------------
|
|
58
|
+
# xAI (Grok)
|
|
59
|
+
# -------------------------------------------------------------------------
|
|
60
|
+
attr_accessor :xai_api_key
|
|
61
|
+
attr_accessor :xai_api_url
|
|
62
|
+
|
|
63
|
+
# -------------------------------------------------------------------------
|
|
64
|
+
# DeepSeek
|
|
65
|
+
# -------------------------------------------------------------------------
|
|
66
|
+
attr_accessor :deepseek_api_key
|
|
67
|
+
attr_accessor :deepseek_api_url
|
|
68
|
+
|
|
69
|
+
# -------------------------------------------------------------------------
|
|
70
|
+
# Mistral
|
|
71
|
+
# -------------------------------------------------------------------------
|
|
72
|
+
attr_accessor :mistral_api_key
|
|
73
|
+
attr_accessor :mistral_api_url
|
|
74
|
+
|
|
75
|
+
# -------------------------------------------------------------------------
|
|
76
|
+
# Ollama (local — key is optional)
|
|
77
|
+
# -------------------------------------------------------------------------
|
|
78
|
+
attr_accessor :ollama_api_key
|
|
79
|
+
attr_accessor :ollama_api_base
|
|
80
|
+
|
|
81
|
+
# -------------------------------------------------------------------------
|
|
82
|
+
# Perplexity
|
|
83
|
+
# -------------------------------------------------------------------------
|
|
84
|
+
attr_accessor :perplexity_api_key
|
|
85
|
+
attr_accessor :perplexity_api_url
|
|
86
|
+
|
|
87
|
+
# -------------------------------------------------------------------------
|
|
88
|
+
# GPUStack (self-hosted — key is optional)
|
|
89
|
+
# -------------------------------------------------------------------------
|
|
90
|
+
attr_accessor :gpustack_api_key
|
|
91
|
+
attr_accessor :gpustack_api_base
|
|
92
|
+
|
|
93
|
+
# -------------------------------------------------------------------------
|
|
94
|
+
# Azure OpenAI Service
|
|
95
|
+
# -------------------------------------------------------------------------
|
|
96
|
+
attr_accessor :azure_api_key # api-key header (preferred)
|
|
97
|
+
attr_accessor :azure_ai_auth_token # Bearer token (alternative to api-key)
|
|
98
|
+
attr_accessor :azure_api_base # e.g. "https://my-resource.openai.azure.com"
|
|
99
|
+
attr_accessor :azure_api_version # e.g. "2024-05-01-preview"
|
|
100
|
+
|
|
101
|
+
# -------------------------------------------------------------------------
|
|
102
|
+
# Custom providers
|
|
103
|
+
#
|
|
104
|
+
# Register any OpenAI-compatible endpoint under an arbitrary name:
|
|
105
|
+
#
|
|
106
|
+
# ActiveHarness.configure do |config|
|
|
107
|
+
# config.custom["MyLocal"]["url"] = "http://localhost:8080/v1/chat/completions"
|
|
108
|
+
# config.custom["MyLocal"]["api_key"] = ENV["MYLOCAL_API_KEY"] # omit if no auth
|
|
109
|
+
#
|
|
110
|
+
# config.custom["SecondProvider"]["url"] = "https://second.example.com/v1/chat/completions"
|
|
111
|
+
# config.custom["SecondProvider"]["api_key"] = ENV["SECOND_API_KEY"]
|
|
112
|
+
# end
|
|
113
|
+
#
|
|
114
|
+
# Use in an agent:
|
|
115
|
+
# model do
|
|
116
|
+
# use provider: :custom, name: "MyLocal", model: "llama3.2"
|
|
117
|
+
# fallback provider: :custom, name: "SecondProvider", model: "mixtral"
|
|
118
|
+
# end
|
|
119
|
+
# -------------------------------------------------------------------------
|
|
120
|
+
def custom
|
|
121
|
+
@custom ||= Hash.new { |h, k| h[k] = {} }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# -------------------------------------------------------------------------
|
|
125
|
+
# Defaults — all keys fall back to the corresponding ENV variable so that
|
|
126
|
+
# existing ENV-based setups keep working without any changes.
|
|
127
|
+
# -------------------------------------------------------------------------
|
|
128
|
+
def initialize
|
|
129
|
+
@request_timeout = 10
|
|
130
|
+
@retry_default_attempts = 3
|
|
131
|
+
@retry_default_delay = 1.0
|
|
132
|
+
|
|
133
|
+
@openai_api_key = ENV["OPENAI_API_KEY"]
|
|
134
|
+
@openai_api_url = "https://api.openai.com/v1/chat/completions"
|
|
135
|
+
|
|
136
|
+
@anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
137
|
+
@anthropic_api_url = "https://api.anthropic.com/v1/messages"
|
|
138
|
+
|
|
139
|
+
@gemini_api_key = ENV["GEMINI_API_KEY"]
|
|
140
|
+
@gemini_api_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
|
|
141
|
+
|
|
142
|
+
@groq_api_key = ENV["GROQ_API_KEY"]
|
|
143
|
+
@groq_api_url = "https://api.groq.com/openai/v1/chat/completions"
|
|
144
|
+
|
|
145
|
+
@openrouter_api_key = ENV["OPENROUTER_API_KEY"]
|
|
146
|
+
@openrouter_api_url = "https://openrouter.ai/api/v1/chat/completions"
|
|
147
|
+
@openrouter_http_referer = "https://github.com/the-teacher/ActiveHarness"
|
|
148
|
+
|
|
149
|
+
@xai_api_key = ENV["XAI_API_KEY"]
|
|
150
|
+
@xai_api_url = "https://api.x.ai/v1/chat/completions"
|
|
151
|
+
|
|
152
|
+
@deepseek_api_key = ENV["DEEPSEEK_API_KEY"]
|
|
153
|
+
@deepseek_api_url = "https://api.deepseek.com/v1/chat/completions"
|
|
154
|
+
|
|
155
|
+
@mistral_api_key = ENV["MISTRAL_API_KEY"]
|
|
156
|
+
@mistral_api_url = "https://api.mistral.ai/v1/chat/completions"
|
|
157
|
+
|
|
158
|
+
@ollama_api_key = ENV["OLLAMA_API_KEY"] # nil if not set — key is optional
|
|
159
|
+
@ollama_api_base = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434")
|
|
160
|
+
|
|
161
|
+
@perplexity_api_key = ENV["PERPLEXITY_API_KEY"]
|
|
162
|
+
@perplexity_api_url = "https://api.perplexity.ai/chat/completions"
|
|
163
|
+
|
|
164
|
+
@gpustack_api_key = ENV["GPUSTACK_API_KEY"] # nil if not set — key is optional
|
|
165
|
+
@gpustack_api_base = ENV["GPUSTACK_API_BASE"]
|
|
166
|
+
|
|
167
|
+
@azure_api_key = ENV["AZURE_API_KEY"]
|
|
168
|
+
@azure_ai_auth_token = ENV["AZURE_AI_AUTH_TOKEN"]
|
|
169
|
+
@azure_api_base = ENV["AZURE_API_BASE"]
|
|
170
|
+
@azure_api_version = ENV.fetch("AZURE_API_VERSION", "2024-05-01-preview")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module ActiveHarness
|
|
2
|
+
module Http
|
|
3
|
+
# Wraps a block with automatic retry on transient errors.
|
|
4
|
+
# Uses exponential backoff: delay doubles after each failed attempt.
|
|
5
|
+
#
|
|
6
|
+
# Example:
|
|
7
|
+
# RetryPolicy.new(max_attempts: 3, base_delay: 0.5).run do
|
|
8
|
+
# http_client.post(...)
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Disable retries entirely:
|
|
12
|
+
# RetryPolicy.new(max_attempts: 1).run { ... }
|
|
13
|
+
#
|
|
14
|
+
class RetryPolicy
|
|
15
|
+
RETRYABLE_ERRORS = [
|
|
16
|
+
Errors::TimeoutError,
|
|
17
|
+
Errors::RateLimitError,
|
|
18
|
+
Errors::ProviderUnavailableError,
|
|
19
|
+
Errors::ServerError
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# @param max_attempts [Integer] total number of attempts (1 = no retries)
|
|
23
|
+
# @param base_delay [Float] seconds before 1st retry; doubles each round
|
|
24
|
+
# @param errors [Array] error classes that trigger a retry
|
|
25
|
+
def initialize(max_attempts: 3, base_delay: 1.0, errors: RETRYABLE_ERRORS)
|
|
26
|
+
@max_attempts = max_attempts
|
|
27
|
+
@base_delay = base_delay
|
|
28
|
+
@errors = errors
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @yieldreturn [Object] result of the block on success
|
|
32
|
+
# @raise last error after all attempts are exhausted
|
|
33
|
+
def run
|
|
34
|
+
attempt = 0
|
|
35
|
+
begin
|
|
36
|
+
attempt += 1
|
|
37
|
+
yield
|
|
38
|
+
rescue *@errors => e
|
|
39
|
+
raise if attempt >= @max_attempts
|
|
40
|
+
|
|
41
|
+
sleep(@base_delay * (2**(attempt - 1)))
|
|
42
|
+
retry
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -5,7 +5,6 @@ module ActiveHarness
|
|
|
5
5
|
# Anthropic Claude — native Messages API (not OpenAI-compatible).
|
|
6
6
|
# https://docs.anthropic.com/en/api/messages
|
|
7
7
|
class Anthropic < Base
|
|
8
|
-
API_URL = URI("https://api.anthropic.com/v1/messages")
|
|
9
8
|
ANTHROPIC_VERSION = "2023-06-01"
|
|
10
9
|
DEFAULT_MAX_TOKENS = 1024
|
|
11
10
|
|
|
@@ -20,7 +19,7 @@ module ActiveHarness
|
|
|
20
19
|
}
|
|
21
20
|
body[:system] = system_msg if system_msg
|
|
22
21
|
|
|
23
|
-
raw = post_json(
|
|
22
|
+
raw = post_json(URI(config.anthropic_api_url),
|
|
24
23
|
headers: {
|
|
25
24
|
"Content-Type" => "application/json",
|
|
26
25
|
"x-api-key" => api_key,
|
|
@@ -52,8 +51,8 @@ module ActiveHarness
|
|
|
52
51
|
end
|
|
53
52
|
|
|
54
53
|
def api_key
|
|
55
|
-
key =
|
|
56
|
-
raise Errors::InvalidApiKeyError, "
|
|
54
|
+
key = config.anthropic_api_key.to_s
|
|
55
|
+
raise Errors::InvalidApiKeyError, "anthropic_api_key is not configured" if key.empty?
|
|
57
56
|
key
|
|
58
57
|
end
|
|
59
58
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Azure OpenAI Service — deployment-based API.
|
|
6
|
+
# https://learn.microsoft.com/en-us/azure/ai-services/openai/reference
|
|
7
|
+
#
|
|
8
|
+
# The `model:` parameter is treated as the **deployment name** you created
|
|
9
|
+
# in the Azure portal (not the underlying model name).
|
|
10
|
+
#
|
|
11
|
+
# Required config (or ENV fallback):
|
|
12
|
+
# config.azure_api_base — "https://my-resource.openai.azure.com"
|
|
13
|
+
# config.azure_api_key — your resource API key
|
|
14
|
+
# (alternatively: config.azure_ai_auth_token for OAuth bearer)
|
|
15
|
+
#
|
|
16
|
+
# Optional config:
|
|
17
|
+
# config.azure_api_version — defaults to "2024-05-01-preview"
|
|
18
|
+
#
|
|
19
|
+
# Resulting endpoint:
|
|
20
|
+
# POST {azure_api_base}/openai/deployments/{deployment}/chat/completions
|
|
21
|
+
# ?api-version={azure_api_version}
|
|
22
|
+
#
|
|
23
|
+
# Example agent config:
|
|
24
|
+
# model do
|
|
25
|
+
# use provider: :azure, model: "my-gpt4o-deployment", temperature: 0.7
|
|
26
|
+
# end
|
|
27
|
+
class Azure < Base
|
|
28
|
+
def call(model:, messages:, temperature: 0.7)
|
|
29
|
+
url = build_url(model)
|
|
30
|
+
|
|
31
|
+
raw = post_json(url,
|
|
32
|
+
headers: {
|
|
33
|
+
"Content-Type" => "application/json"
|
|
34
|
+
}.merge(auth_header),
|
|
35
|
+
body: { messages: messages, temperature: temperature }
|
|
36
|
+
)
|
|
37
|
+
data = parse!(raw)
|
|
38
|
+
handle_error!(data)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
42
|
+
provider: :azure,
|
|
43
|
+
model: data["model"] || model,
|
|
44
|
+
usage: extract_usage_openai(data)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_url(deployment)
|
|
51
|
+
URI("#{api_base}/openai/deployments/#{deployment}/chat/completions?api-version=#{config.azure_api_version}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def api_base
|
|
55
|
+
base = config.azure_api_base.to_s
|
|
56
|
+
raise Errors::InvalidRequestError, "azure_api_base is not configured" if base.empty?
|
|
57
|
+
base.chomp("/")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Azure accepts either a resource API key (header: api-key)
|
|
61
|
+
# or an OAuth2 bearer token (header: Authorization).
|
|
62
|
+
def auth_header
|
|
63
|
+
if (key = config.azure_api_key.to_s) && !key.empty?
|
|
64
|
+
{ "api-key" => key }
|
|
65
|
+
elsif (token = config.azure_ai_auth_token.to_s) && !token.empty?
|
|
66
|
+
{ "Authorization" => "Bearer #{token}" }
|
|
67
|
+
else
|
|
68
|
+
raise Errors::InvalidApiKeyError,
|
|
69
|
+
"Neither azure_api_key nor azure_ai_auth_token is configured"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle_error!(data)
|
|
74
|
+
return unless data["error"]
|
|
75
|
+
|
|
76
|
+
msg = data.dig("error", "message").to_s
|
|
77
|
+
code = data.dig("error", "code").to_s
|
|
78
|
+
type = data.dig("error", "innererror", "code").to_s
|
|
79
|
+
metadata = data["error"].reject { |k, _| %w[message code innererror].include?(k) }
|
|
80
|
+
metadata = nil if metadata.empty?
|
|
81
|
+
|
|
82
|
+
case code
|
|
83
|
+
when "401", "invalid_api_key", "unauthorized", "AccessDenied"
|
|
84
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
85
|
+
when "429", "TooManyRequests"
|
|
86
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
87
|
+
when "ContentFilter", "content_filter"
|
|
88
|
+
raise Errors::SafetyBlockedError.new(msg, error_code: code, metadata: metadata)
|
|
89
|
+
when "500", "502", "503", "504", "ServiceUnavailable"
|
|
90
|
+
raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
|
|
91
|
+
else
|
|
92
|
+
raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module ActiveHarness
|
|
2
|
+
module Providers
|
|
3
|
+
# AWS Bedrock — stub provider.
|
|
4
|
+
#
|
|
5
|
+
# Bedrock requires AWS Signature V4 request signing, which is non-trivial
|
|
6
|
+
# to implement and carries AWS SDK dependencies. This stub raises a clear
|
|
7
|
+
# error so that the agent falls through to the next model in its fallback chain.
|
|
8
|
+
#
|
|
9
|
+
# To use Bedrock in production, please look for a dedicated gem, for example:
|
|
10
|
+
# gem "active_harness-bedrock" (not yet released — contributions welcome)
|
|
11
|
+
#
|
|
12
|
+
# Example agent config (will fall through to the next fallback):
|
|
13
|
+
# model do
|
|
14
|
+
# use provider: :bedrock, model: "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
|
15
|
+
# fallback provider: :anthropic, model: "claude-3-5-sonnet-20241022"
|
|
16
|
+
# end
|
|
17
|
+
class Bedrock < Base
|
|
18
|
+
STUB_MESSAGE = <<~MSG.strip
|
|
19
|
+
ActiveHarness: AWS Bedrock provider is not built-in.
|
|
20
|
+
Bedrock requires AWS Signature V4 signing — please use a dedicated gem.
|
|
21
|
+
Falling through to the next model in the fallback chain.
|
|
22
|
+
MSG
|
|
23
|
+
|
|
24
|
+
def call(model:, messages:, temperature: 0.7) # rubocop:disable Lint/UnusedMethodArgument
|
|
25
|
+
raise Errors::ProviderUnavailableError, STUB_MESSAGE
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|