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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eaa18bf8f82b46ce0066c77d60b142948ecf00c1a682c880aeadd7bd4f4d2952
4
- data.tar.gz: b1cdf2d3a20c6effdbf88f8eb0ee5df2ce627cc0ff6ba0790b7d841a77fa6444
3
+ metadata.gz: 15017e6a42df89f5372596d3b75deb192117a680ad297196eda52111a8931a96
4
+ data.tar.gz: 135fdfb6b453c4c2bfaef6ff8ed7f022b2c3fb1b45923f2ed282701fd106864d
5
5
  SHA512:
6
- metadata.gz: '091e1439cb133d99588573c0543779edd64cee3154f73e9b682a9b1da9c6c85a223b27f94ac9980ea9db1dc6478f9d964416295c665e2a5151c41160dd9b07c4'
7
- data.tar.gz: 9af3ad6a8c3b5645a7da0f6f86baed21c5a36c99d905ae3595b39e2a79c068802aa561d6d6e723e38401df9d970ae1b2b487d69dc0f4e3414cd46fd42e098f5d
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 << { provider: provider, model: model, temperature: temperature }.compact
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: -> { Providers::OpenAI.new },
25
- openrouter: -> { Providers::OpenRouter.new },
26
- groq: -> { Providers::Groq.new },
27
- gemini: -> { Providers::Gemini.new },
28
- anthropic: -> { Providers::Anthropic.new }
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] = @stream if @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
@@ -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.result
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 initialize(input: nil, context: {}, models: nil, memory: nil, stream: nil)
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, stream: nil)
57
- @input = input if input
58
- @stream = stream if stream
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(API_URL,
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 = ENV["ANTHROPIC_API_KEY"].to_s
56
- raise Errors::InvalidApiKeyError, "ANTHROPIC_API_KEY is not set" if key.empty?
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
@@ -8,6 +8,10 @@ module ActiveHarness
8
8
 
9
9
  private
10
10
 
11
+ def config
12
+ ActiveHarness.config
13
+ end
14
+
11
15
  def post_json(url, headers:, body:, timeout: 30)
12
16
  HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout)
13
17
  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