active_harness 0.2.7 → 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.
@@ -0,0 +1,62 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # DeepSeek — OpenAI-compatible API.
6
+ # https://platform.deepseek.com/api-docs
7
+ class DeepSeek < Base
8
+ def call(model:, messages:, temperature: 0.7)
9
+ raw = post_json(URI(config.deepseek_api_url),
10
+ headers: {
11
+ "Content-Type" => "application/json",
12
+ "Authorization" => "Bearer #{api_key}"
13
+ },
14
+ body: { model: model, messages: messages, temperature: temperature }
15
+ )
16
+ data = parse!(raw)
17
+ handle_error!(data)
18
+
19
+ {
20
+ content: data.dig("choices", 0, "message", "content").to_s.strip,
21
+ provider: :deepseek,
22
+ model: data["model"] || model,
23
+ usage: extract_usage_openai(data)
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def api_key
30
+ key = config.deepseek_api_key.to_s
31
+ raise Errors::InvalidApiKeyError, "deepseek_api_key is not configured" if key.empty?
32
+ key
33
+ end
34
+
35
+ def handle_error!(data)
36
+ return unless data["error"]
37
+
38
+ msg = data.dig("error", "message").to_s
39
+ code = data.dig("error", "code").to_s
40
+ type = data.dig("error", "type").to_s
41
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
42
+ metadata = nil if metadata.empty?
43
+
44
+ case code
45
+ when "invalid_api_key", "unauthorized"
46
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
47
+ when "rate_limit_exceeded"
48
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
49
+ when "500", "502", "503", "504"
50
+ raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
51
+ else
52
+ case type
53
+ when "server_error"
54
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
55
+ else
56
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -5,10 +5,8 @@ module ActiveHarness
5
5
  # Google Gemini — OpenAI-compatible endpoint (beta).
6
6
  # https://ai.google.dev/gemini-api/docs/openai
7
7
  class Gemini < Base
8
- API_URL = URI("https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")
9
-
10
8
  def call(model:, messages:, temperature: 0.7)
11
- raw = post_json(API_URL,
9
+ raw = post_json(URI(config.gemini_api_url),
12
10
  headers: {
13
11
  "Content-Type" => "application/json",
14
12
  "Authorization" => "Bearer #{api_key}"
@@ -29,8 +27,8 @@ module ActiveHarness
29
27
  private
30
28
 
31
29
  def api_key
32
- key = ENV["GEMINI_API_KEY"].to_s
33
- raise Errors::InvalidApiKeyError, "GEMINI_API_KEY is not set" if key.empty?
30
+ key = config.gemini_api_key.to_s
31
+ raise Errors::InvalidApiKeyError, "gemini_api_key is not configured" if key.empty?
34
32
  key
35
33
  end
36
34
 
@@ -0,0 +1,78 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # GPUStack — self-hosted GPU inference server, OpenAI-compatible API.
6
+ # https://docs.gpustack.ai/latest/user-guide/inference-openai-compatible-apis/
7
+ #
8
+ # GPUSTACK_API_BASE is required (e.g. "http://my-gpustack-server:80").
9
+ # GPUSTACK_API_KEY is optional (needed only if the server has auth enabled).
10
+ #
11
+ # Example:
12
+ # model do
13
+ # use provider: :gpustack, model: "Qwen/Qwen2.5-7B-Instruct-GGUF"
14
+ # end
15
+ class GPUStack < Base
16
+ def call(model:, messages:, temperature: 0.7)
17
+ url = URI("#{api_base}/v1/chat/completions")
18
+
19
+ headers = { "Content-Type" => "application/json" }
20
+ key = api_key
21
+ headers["Authorization"] = "Bearer #{key}" if key
22
+
23
+ raw = post_json(url,
24
+ headers: headers,
25
+ body: { model: model, messages: messages, temperature: temperature }
26
+ )
27
+ data = parse!(raw)
28
+ handle_error!(data)
29
+
30
+ {
31
+ content: data.dig("choices", 0, "message", "content").to_s.strip,
32
+ provider: :gpustack,
33
+ model: data["model"] || model,
34
+ usage: extract_usage_openai(data)
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def api_base
41
+ base = config.gpustack_api_base.to_s
42
+ raise Errors::InvalidRequestError, "gpustack_api_base is not configured" if base.empty?
43
+ base.chomp("/")
44
+ end
45
+
46
+ def api_key
47
+ key = config.gpustack_api_key.to_s
48
+ key.empty? ? nil : key
49
+ end
50
+
51
+ def handle_error!(data)
52
+ return unless data["error"]
53
+
54
+ msg = data.dig("error", "message").to_s
55
+ code = data.dig("error", "code").to_s
56
+ type = data.dig("error", "type").to_s
57
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
58
+ metadata = nil if metadata.empty?
59
+
60
+ case code
61
+ when "invalid_api_key", "unauthorized", "401"
62
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
63
+ when "429"
64
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
65
+ when "500", "502", "503", "504"
66
+ raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
67
+ else
68
+ case type
69
+ when "server_error"
70
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
71
+ else
72
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -5,10 +5,8 @@ module ActiveHarness
5
5
  # Groq — OpenAI-compatible API with fast inference.
6
6
  # https://console.groq.com/docs/openai
7
7
  class Groq < Base
8
- API_URL = URI("https://api.groq.com/openai/v1/chat/completions")
9
-
10
8
  def call(model:, messages:, temperature: 0.7)
11
- raw = post_json(API_URL,
9
+ raw = post_json(URI(config.groq_api_url),
12
10
  headers: {
13
11
  "Content-Type" => "application/json",
14
12
  "Authorization" => "Bearer #{api_key}"
@@ -29,8 +27,8 @@ module ActiveHarness
29
27
  private
30
28
 
31
29
  def api_key
32
- key = ENV["GROQ_API_KEY"].to_s
33
- raise Errors::InvalidApiKeyError, "GROQ_API_KEY is not set" if key.empty?
30
+ key = config.groq_api_key.to_s
31
+ raise Errors::InvalidApiKeyError, "groq_api_key is not configured" if key.empty?
34
32
  key
35
33
  end
36
34
 
@@ -0,0 +1,63 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # Mistral AI — OpenAI-compatible API.
6
+ # https://docs.mistral.ai/api
7
+ class Mistral < Base
8
+ def call(model:, messages:, temperature: 0.7)
9
+ raw = post_json(URI(config.mistral_api_url),
10
+ headers: {
11
+ "Content-Type" => "application/json",
12
+ "Authorization" => "Bearer #{api_key}"
13
+ },
14
+ body: { model: model, messages: messages, temperature: temperature }
15
+ )
16
+ data = parse!(raw)
17
+ handle_error!(data)
18
+
19
+ {
20
+ content: data.dig("choices", 0, "message", "content").to_s.strip,
21
+ provider: :mistral,
22
+ model: data["model"] || model,
23
+ usage: extract_usage_openai(data)
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def api_key
30
+ key = config.mistral_api_key.to_s
31
+ raise Errors::InvalidApiKeyError, "mistral_api_key is not configured" if key.empty?
32
+ key
33
+ end
34
+
35
+ def handle_error!(data)
36
+ return unless data["error"]
37
+
38
+ msg = data.dig("error", "message").to_s
39
+ code = data.dig("error", "code").to_s
40
+ type = data.dig("error", "type").to_s
41
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
42
+ metadata = nil if metadata.empty?
43
+
44
+ case code
45
+ when "1901" # unauthorized
46
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
47
+ when "1902" # rate limit
48
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
49
+ when "500", "502",
50
+ "503", "504"
51
+ raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
52
+ else
53
+ case type
54
+ when "server_error"
55
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
56
+ else
57
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,67 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # Ollama — local model inference server, OpenAI-compatible API.
6
+ # https://ollama.com/blog/openai-compatibility
7
+ #
8
+ # Set OLLAMA_API_BASE to override the default local address.
9
+ # OLLAMA_API_KEY is optional (needed only if Ollama is behind a proxy with auth).
10
+ #
11
+ # Example:
12
+ # model do
13
+ # use provider: :ollama, model: "llama3.2"
14
+ # end
15
+ class Ollama < Base
16
+ def call(model:, messages:, temperature: 0.7)
17
+ url = URI("#{api_base}/v1/chat/completions")
18
+
19
+ headers = { "Content-Type" => "application/json" }
20
+ key = api_key
21
+ headers["Authorization"] = "Bearer #{key}" if key
22
+
23
+ raw = post_json(url,
24
+ headers: headers,
25
+ body: { model: model, messages: messages, temperature: temperature }
26
+ )
27
+ data = parse!(raw)
28
+ handle_error!(data)
29
+
30
+ {
31
+ content: data.dig("choices", 0, "message", "content").to_s.strip,
32
+ provider: :ollama,
33
+ model: data["model"] || model,
34
+ usage: extract_usage_openai(data)
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def api_base
41
+ config.ollama_api_base.to_s.chomp("/")
42
+ end
43
+
44
+ # Ollama does not require an API key by default.
45
+ def api_key
46
+ key = config.ollama_api_key.to_s
47
+ key.empty? ? nil : key
48
+ end
49
+
50
+ def handle_error!(data)
51
+ return unless data["error"]
52
+
53
+ msg = data.dig("error", "message").to_s
54
+ code = data.dig("error", "code").to_s
55
+ metadata = data["error"].reject { |k, _| %w[message code].include?(k) }
56
+ metadata = nil if metadata.empty?
57
+
58
+ case code
59
+ when "500", "502", "503", "504"
60
+ raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
61
+ else
62
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -3,14 +3,12 @@ require "uri"
3
3
  module ActiveHarness
4
4
  module Providers
5
5
  class OpenAI < Base
6
- API_URL = URI("https://api.openai.com/v1/chat/completions")
7
-
8
6
  # @param model [String]
9
7
  # @param messages [Array<Hash>] [{role:, content:}, ...]
10
8
  # @param temperature [Float]
11
9
  # @return [Hash] { content:, provider:, model: }
12
10
  def call(model:, messages:, temperature: 0.7)
13
- raw = post_json(API_URL,
11
+ raw = post_json(URI(config.openai_api_url),
14
12
  headers: {
15
13
  "Content-Type" => "application/json",
16
14
  "Authorization" => "Bearer #{api_key}"
@@ -31,8 +29,8 @@ module ActiveHarness
31
29
  private
32
30
 
33
31
  def api_key
34
- key = ENV["OPENAI_API_KEY"].to_s
35
- raise Errors::InvalidApiKeyError, "OPENAI_API_KEY is not set" if key.empty?
32
+ key = config.openai_api_key.to_s
33
+ raise Errors::InvalidApiKeyError, "openai_api_key is not configured" if key.empty?
36
34
  key
37
35
  end
38
36
 
@@ -4,8 +4,6 @@ module ActiveHarness
4
4
  module Providers
5
5
  # OpenRouter — OpenAI-compatible API that proxies many models.
6
6
  class OpenRouter < Base
7
- API_URL = URI("https://openrouter.ai/api/v1/chat/completions")
8
-
9
7
  # @param model [String]
10
8
  # @param messages [Array<Hash>] [{role:, content:}, ...]
11
9
  # @param temperature [Float]
@@ -15,17 +13,17 @@ module ActiveHarness
15
13
  headers = {
16
14
  "Content-Type" => "application/json",
17
15
  "Authorization" => "Bearer #{api_key}",
18
- "HTTP-Referer" => "https://github.com/the-teacher/ActiveHarness"
16
+ "HTTP-Referer" => config.openrouter_http_referer
19
17
  }
20
18
  body = { model: model, messages: messages, temperature: temperature }
21
19
 
22
20
  if stream
23
21
  body[:stream] = true
24
- content = post_json_stream(API_URL, headers: headers, body: body, on_token: stream)
22
+ content = post_json_stream(URI(config.openrouter_api_url), headers: headers, body: body, on_token: stream)
25
23
  return { content: content, provider: :openrouter, model: model }
26
24
  end
27
25
 
28
- raw = post_json(API_URL, headers: headers, body: body)
26
+ raw = post_json(URI(config.openrouter_api_url), headers: headers, body: body)
29
27
  data = parse!(raw)
30
28
  handle_error!(data)
31
29
 
@@ -40,8 +38,8 @@ module ActiveHarness
40
38
  private
41
39
 
42
40
  def api_key
43
- key = ENV["OPENROUTER_API_KEY"].to_s
44
- raise Errors::InvalidApiKeyError, "OPENROUTER_API_KEY is not set" if key.empty?
41
+ key = config.openrouter_api_key.to_s
42
+ raise Errors::InvalidApiKeyError, "openrouter_api_key is not configured" if key.empty?
45
43
  key
46
44
  end
47
45
 
@@ -0,0 +1,62 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # Perplexity — OpenAI-compatible API with web-search-augmented models.
6
+ # https://docs.perplexity.ai/api-reference/chat-completions
7
+ class Perplexity < Base
8
+ def call(model:, messages:, temperature: 0.7)
9
+ raw = post_json(URI(config.perplexity_api_url),
10
+ headers: {
11
+ "Content-Type" => "application/json",
12
+ "Authorization" => "Bearer #{api_key}"
13
+ },
14
+ body: { model: model, messages: messages, temperature: temperature }
15
+ )
16
+ data = parse!(raw)
17
+ handle_error!(data)
18
+
19
+ {
20
+ content: data.dig("choices", 0, "message", "content").to_s.strip,
21
+ provider: :perplexity,
22
+ model: data["model"] || model,
23
+ usage: extract_usage_openai(data)
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def api_key
30
+ key = config.perplexity_api_key.to_s
31
+ raise Errors::InvalidApiKeyError, "perplexity_api_key is not configured" if key.empty?
32
+ key
33
+ end
34
+
35
+ def handle_error!(data)
36
+ return unless data["error"]
37
+
38
+ msg = data.dig("error", "message").to_s
39
+ code = data.dig("error", "code").to_s
40
+ type = data.dig("error", "type").to_s
41
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
42
+ metadata = nil if metadata.empty?
43
+
44
+ case code
45
+ when "401"
46
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
47
+ when "429"
48
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
49
+ when "500", "502", "503", "504"
50
+ raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
51
+ else
52
+ case type
53
+ when "server_error"
54
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
55
+ else
56
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,34 @@
1
+ module ActiveHarness
2
+ module Providers
3
+ # Google Vertex AI — stub provider.
4
+ #
5
+ # Vertex AI requires Google Cloud OAuth2 authentication via Service Account
6
+ # credentials (googleauth gem) or Application Default Credentials.
7
+ # This stub raises a clear error so that the agent falls through to the
8
+ # next model in its fallback chain.
9
+ #
10
+ # To use Vertex AI in production, please look for a dedicated gem, for example:
11
+ # gem "active_harness-vertexai" (not yet released — contributions welcome)
12
+ #
13
+ # For most use cases, consider using the built-in :gemini provider instead:
14
+ # it accesses Google's Gemini models via a simple API key (no OAuth needed).
15
+ #
16
+ # Example agent config (will fall through to the next fallback):
17
+ # model do
18
+ # use provider: :vertexai, model: "gemini-2.0-flash"
19
+ # fallback provider: :gemini, model: "gemini-2.0-flash"
20
+ # end
21
+ class VertexAI < Base
22
+ STUB_MESSAGE = <<~MSG.strip
23
+ ActiveHarness: Google Vertex AI provider is not built-in.
24
+ Vertex AI requires Google Cloud OAuth2 credentials (googleauth gem) — please use a dedicated gem.
25
+ Consider using the built-in :gemini provider instead (simple API key, same models).
26
+ Falling through to the next model in the fallback chain.
27
+ MSG
28
+
29
+ def call(model:, messages:, temperature: 0.7) # rubocop:disable Lint/UnusedMethodArgument
30
+ raise Errors::ProviderUnavailableError, STUB_MESSAGE
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,62 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # xAI (Grok) — OpenAI-compatible API.
6
+ # https://docs.x.ai/api
7
+ class XAI < Base
8
+ def call(model:, messages:, temperature: 0.7)
9
+ raw = post_json(URI(config.xai_api_url),
10
+ headers: {
11
+ "Content-Type" => "application/json",
12
+ "Authorization" => "Bearer #{api_key}"
13
+ },
14
+ body: { model: model, messages: messages, temperature: temperature }
15
+ )
16
+ data = parse!(raw)
17
+ handle_error!(data)
18
+
19
+ {
20
+ content: data.dig("choices", 0, "message", "content").to_s.strip,
21
+ provider: :xai,
22
+ model: data["model"] || model,
23
+ usage: extract_usage_openai(data)
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def api_key
30
+ key = config.xai_api_key.to_s
31
+ raise Errors::InvalidApiKeyError, "xai_api_key is not configured" if key.empty?
32
+ key
33
+ end
34
+
35
+ def handle_error!(data)
36
+ return unless data["error"]
37
+
38
+ msg = data.dig("error", "message").to_s
39
+ code = data.dig("error", "code").to_s
40
+ type = data.dig("error", "type").to_s
41
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
42
+ metadata = nil if metadata.empty?
43
+
44
+ case code
45
+ when "invalid_api_key", "unauthorized"
46
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
47
+ when "rate_limit_exceeded"
48
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
49
+ when "500", "502", "503", "504"
50
+ raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
51
+ else
52
+ case type
53
+ when "server_error"
54
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
55
+ else
56
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,13 +1,25 @@
1
+ require_relative "active_harness/configuration"
1
2
  require_relative "active_harness/core/errors"
2
3
  require_relative "active_harness/result"
3
4
  require_relative "active_harness/http/client"
4
5
  require_relative "active_harness/http/streaming_client"
6
+ require_relative "active_harness/http/retry_policy"
5
7
  require_relative "active_harness/providers/base"
6
8
  require_relative "active_harness/providers/openai"
7
9
  require_relative "active_harness/providers/openrouter"
8
10
  require_relative "active_harness/providers/groq"
9
11
  require_relative "active_harness/providers/gemini"
10
12
  require_relative "active_harness/providers/anthropic"
13
+ require_relative "active_harness/providers/xai"
14
+ require_relative "active_harness/providers/deepseek"
15
+ require_relative "active_harness/providers/mistral"
16
+ require_relative "active_harness/providers/ollama"
17
+ require_relative "active_harness/providers/perplexity"
18
+ require_relative "active_harness/providers/gpustack"
19
+ require_relative "active_harness/providers/azure"
20
+ require_relative "active_harness/providers/bedrock"
21
+ require_relative "active_harness/providers/vertexai"
22
+ require_relative "active_harness/providers/custom"
11
23
  require_relative "active_harness/memory"
12
24
  require_relative "active_harness/agent"
13
25
  require_relative "active_harness/tribunal"
@@ -17,4 +29,27 @@ require_relative "active_harness/railtie" if defined?(Rails::Railtie)
17
29
 
18
30
  module ActiveHarness
19
31
  VERSION = "0.2.0"
32
+
33
+ class << self
34
+ # Configure ActiveHarness.
35
+ #
36
+ # ActiveHarness.configure do |config|
37
+ # config.openai_api_key = ENV["OPENAI_API_KEY"]
38
+ # config.openai_api_url = "https://api.openai.com/v1/chat/completions"
39
+ # end
40
+ def configure
41
+ yield config
42
+ end
43
+
44
+ # Returns the singleton Configuration instance.
45
+ # Lazily initialized on first access.
46
+ def config
47
+ @config ||= Configuration.new
48
+ end
49
+
50
+ # Reset config to defaults (useful in tests).
51
+ def reset_config!
52
+ @config = nil
53
+ end
54
+ end
20
55
  end
@@ -16,6 +16,22 @@ module ActiveHarness
16
16
  end
17
17
  end
18
18
 
19
+ def create_initializer
20
+ target = File.join(destination_root, "config", "initializers", "active_harness.rb")
21
+ return if File.exist?(target)
22
+
23
+ copy_file "initializers/active_harness.rb",
24
+ "config/initializers/active_harness.rb"
25
+ end
26
+
27
+ def create_initializer
28
+ target = File.join(destination_root, "config", "initializers", "active_harness.rb")
29
+ return if File.exist?(target)
30
+
31
+ copy_file "initializers/active_harness.rb",
32
+ "config/initializers/active_harness.rb"
33
+ end
34
+
19
35
  def copy_controller
20
36
  target = File.join(destination_root, "app", "controllers", "ai_support_controller.rb")
21
37
  return if File.exist?(target)