active_harness 0.2.7 → 0.2.9
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 +19 -6
- data/lib/active_harness/agent/ruby_llm_backend.rb +90 -0
- data/lib/active_harness/agent.rb +21 -8
- 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: 15017e6a42df89f5372596d3b75deb192117a680ad297196eda52111a8931a96
|
|
4
|
+
data.tar.gz: 135fdfb6b453c4c2bfaef6ff8ed7f022b2c3fb1b45923f2ed282701fd106864d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 253c875d86e0d52535ba46ef206679a8cd5920e10b0cc0bcc82fad743e4a7bed1d1a3a28c8063db72400f17f057070ae0ac8cf61e14a3fe5708a766d9fa3b00f
|
|
7
|
+
data.tar.gz: f7f5a376c825d3ee9ace72f0512cb55f835dc7bad6b4dc419517d14b42f100b78b4bff1e6bf70cc6e7a137624d0ccf91fa71286805e3408560d3eb4fff54b904
|
|
@@ -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
|
|
@@ -21,21 +21,34 @@ module ActiveHarness
|
|
|
21
21
|
].freeze
|
|
22
22
|
|
|
23
23
|
PROVIDERS = {
|
|
24
|
-
openai:
|
|
25
|
-
openrouter:
|
|
26
|
-
groq:
|
|
27
|
-
gemini:
|
|
28
|
-
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 }
|
|
29
39
|
}.freeze
|
|
30
40
|
|
|
31
41
|
private
|
|
32
42
|
|
|
33
43
|
def attempt_model(entry, system_prompt)
|
|
44
|
+
return attempt_via_ruby_llm(entry, system_prompt) if @config[:ruby_llm_backend]
|
|
45
|
+
|
|
34
46
|
provider = resolve_provider(entry[:provider])
|
|
35
47
|
messages = build_messages(system_prompt, @input)
|
|
36
48
|
opts = { model: entry[:model], messages: messages }
|
|
37
49
|
opts[:temperature] = entry[:temperature] if entry[:temperature]
|
|
38
|
-
opts[:stream] = @
|
|
50
|
+
opts[:stream] = @token_stream if @token_stream
|
|
51
|
+
opts[:name] = entry[:name] if entry[:name]
|
|
39
52
|
provider.call(**opts)
|
|
40
53
|
end
|
|
41
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 @token_stream
|
|
58
|
+
response = chat.ask(@input) { |chunk| @token_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
|
@@ -11,8 +11,8 @@ module ActiveHarness
|
|
|
11
11
|
# SupportAgent.call(input: "Hi")
|
|
12
12
|
# SupportAgent.call(input: "Hi", context: { user_id: 42 })
|
|
13
13
|
# SupportAgent.call(input: "Hi", memory: memory)
|
|
14
|
-
def call(input: nil, context: {}, models: nil, memory: nil, stream: nil)
|
|
15
|
-
new(input: input, context: context, models: models, memory: memory, stream: stream).call
|
|
14
|
+
def call(input: nil, context: {}, models: nil, memory: nil, stream: nil, token_stream: nil, event_stream: nil)
|
|
15
|
+
new(input: input, context: context, models: models, memory: memory, stream: stream, token_stream: token_stream, event_stream: event_stream).call
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Each subclass gets its own isolated config hash.
|
|
@@ -28,7 +28,7 @@ module ActiveHarness
|
|
|
28
28
|
# -------------------------------------------------------------------------
|
|
29
29
|
# Instance API
|
|
30
30
|
# -------------------------------------------------------------------------
|
|
31
|
-
attr_accessor :input, :context
|
|
31
|
+
attr_accessor :input, :context, :stream, :token_stream, :event_stream
|
|
32
32
|
attr_reader :result
|
|
33
33
|
|
|
34
34
|
def models=(list)
|
|
@@ -36,12 +36,18 @@ module ActiveHarness
|
|
|
36
36
|
@model_list_proxy = nil
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def
|
|
39
|
+
def memory=(obj)
|
|
40
|
+
@memory = obj
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(input: nil, context: {}, models: nil, memory: nil, stream: nil, token_stream: nil, event_stream: nil)
|
|
40
44
|
@input = input
|
|
41
45
|
@context = context
|
|
42
46
|
@config = self.class.agent_config
|
|
43
47
|
@models_override = Array(models) if models
|
|
44
48
|
@stream = stream
|
|
49
|
+
@token_stream = token_stream
|
|
50
|
+
@event_stream = event_stream
|
|
45
51
|
# memory: can be passed directly or via context[:memory]
|
|
46
52
|
@memory = memory || @context[:memory]
|
|
47
53
|
run_hook(:setup)
|
|
@@ -53,17 +59,23 @@ module ActiveHarness
|
|
|
53
59
|
# Optionally accepts input and stream callback inline:
|
|
54
60
|
# agent.call("What is the capital of Japan?")
|
|
55
61
|
# agent.call("...", stream: ->(token) { print token })
|
|
56
|
-
def call(input = nil,
|
|
57
|
-
@input
|
|
58
|
-
@
|
|
62
|
+
def call(input = nil, token_stream: nil)
|
|
63
|
+
@input = input if input
|
|
64
|
+
@token_stream = token_stream if token_stream
|
|
59
65
|
@memory&.load
|
|
60
66
|
@system_prompt = resolve_system_prompt
|
|
61
67
|
run_hook(:before_call)
|
|
62
68
|
attempts = []
|
|
63
69
|
|
|
70
|
+
cfg = ActiveHarness.config
|
|
71
|
+
|
|
64
72
|
model_list.each do |entry|
|
|
73
|
+
retry_policy = Http::RetryPolicy.new(
|
|
74
|
+
max_attempts: entry[:retry_attempts] || cfg.retry_default_attempts,
|
|
75
|
+
base_delay: entry[:retry_delay] || cfg.retry_default_delay
|
|
76
|
+
)
|
|
65
77
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
-
response = attempt_model(entry, @system_prompt)
|
|
78
|
+
response = retry_policy.run { attempt_model(entry, @system_prompt) }
|
|
67
79
|
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
|
|
68
80
|
result = build_result(response, entry, attempts, elapsed)
|
|
69
81
|
save_to_memory(result)
|
|
@@ -139,4 +151,5 @@ require_relative "agent/hooks"
|
|
|
139
151
|
require_relative "agent/models"
|
|
140
152
|
require_relative "agent/providers"
|
|
141
153
|
require_relative "agent/output_parser"
|
|
154
|
+
require_relative "agent/ruby_llm_backend"
|
|
142
155
|
|
|
@@ -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
|