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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Custom — generic OpenAI-compatible provider for any named endpoint.
|
|
6
|
+
#
|
|
7
|
+
# Configured via ActiveHarness.configure:
|
|
8
|
+
#
|
|
9
|
+
# ActiveHarness.configure do |config|
|
|
10
|
+
# config.custom["MyLocal"]["url"] = "http://localhost:8080/v1/chat/completions"
|
|
11
|
+
# config.custom["MyLocal"]["api_key"] = ENV["MYLOCAL_API_KEY"] # omit if no auth needed
|
|
12
|
+
#
|
|
13
|
+
# config.custom["SecondProvider"]["url"] = "https://second.example.com/v1/chat/completions"
|
|
14
|
+
# config.custom["SecondProvider"]["api_key"] = ENV["SECOND_API_KEY"]
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Use in an agent:
|
|
18
|
+
#
|
|
19
|
+
# model do
|
|
20
|
+
# use provider: :custom, name: "MyLocal", model: "llama3.2"
|
|
21
|
+
# fallback provider: :custom, name: "SecondProvider", model: "mixtral"
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
class Custom < Base
|
|
25
|
+
def call(model:, messages:, temperature: 0.7, name: nil)
|
|
26
|
+
raise Errors::InvalidRequestError,
|
|
27
|
+
"provider: :custom requires a `name:` key — e.g. `use provider: :custom, name: \"MyLocal\", model: \"...\"`" \
|
|
28
|
+
if name.nil? || name.to_s.empty?
|
|
29
|
+
|
|
30
|
+
settings = config.custom[name.to_s]
|
|
31
|
+
|
|
32
|
+
url = settings["url"].to_s
|
|
33
|
+
raise Errors::InvalidRequestError,
|
|
34
|
+
"Custom provider \"#{name}\" has no url configured. " \
|
|
35
|
+
"Set it with: config.custom[\"#{name}\"][\"url\"] = \"https://...\"" \
|
|
36
|
+
if url.empty?
|
|
37
|
+
|
|
38
|
+
headers = { "Content-Type" => "application/json" }
|
|
39
|
+
key = settings["api_key"].to_s
|
|
40
|
+
headers["Authorization"] = "Bearer #{key}" unless key.empty?
|
|
41
|
+
|
|
42
|
+
raw = post_json(URI(url),
|
|
43
|
+
headers: headers,
|
|
44
|
+
body: { model: model, messages: messages, temperature: temperature }
|
|
45
|
+
)
|
|
46
|
+
data = parse!(raw)
|
|
47
|
+
handle_error!(data, name: name)
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
51
|
+
provider: :custom,
|
|
52
|
+
model: data["model"] || model,
|
|
53
|
+
usage: extract_usage_openai(data)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def handle_error!(data, name:)
|
|
60
|
+
return unless data["error"]
|
|
61
|
+
|
|
62
|
+
msg = data.dig("error", "message").to_s
|
|
63
|
+
code = data.dig("error", "code").to_s
|
|
64
|
+
type = data.dig("error", "type").to_s
|
|
65
|
+
metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
|
|
66
|
+
metadata = nil if metadata.empty?
|
|
67
|
+
|
|
68
|
+
case code
|
|
69
|
+
when "invalid_api_key", "unauthorized", "401"
|
|
70
|
+
raise Errors::InvalidApiKeyError.new(
|
|
71
|
+
"[Custom:#{name}] #{msg}", error_code: code, metadata: metadata
|
|
72
|
+
)
|
|
73
|
+
when "429", "rate_limit_exceeded"
|
|
74
|
+
raise Errors::RateLimitError.new(
|
|
75
|
+
"[Custom:#{name}] #{msg}", error_code: code, metadata: metadata
|
|
76
|
+
)
|
|
77
|
+
when "500", "502", "503", "504"
|
|
78
|
+
raise Errors::ProviderUnavailableError.new(
|
|
79
|
+
"[Custom:#{name}] #{msg}", error_code: code, metadata: metadata
|
|
80
|
+
)
|
|
81
|
+
else
|
|
82
|
+
case type
|
|
83
|
+
when "server_error"
|
|
84
|
+
raise Errors::ServerError.new(
|
|
85
|
+
"[Custom:#{name}] #{msg}", error_code: code, metadata: metadata
|
|
86
|
+
)
|
|
87
|
+
else
|
|
88
|
+
raise Errors::InvalidRequestError.new(
|
|
89
|
+
"[Custom:#{name}] #{msg}", error_code: code, metadata: metadata
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -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(
|
|
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 =
|
|
33
|
-
raise Errors::InvalidApiKeyError, "
|
|
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(
|
|
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 =
|
|
33
|
-
raise Errors::InvalidApiKeyError, "
|
|
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(
|
|
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 =
|
|
35
|
-
raise Errors::InvalidApiKeyError, "
|
|
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" =>
|
|
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(
|
|
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(
|
|
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 =
|
|
44
|
-
raise Errors::InvalidApiKeyError, "
|
|
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
|