active_harness 0.1.0 → 0.2.1
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/hooks.rb +75 -0
- data/lib/active_harness/agent/models.rb +147 -0
- data/lib/active_harness/agent/output_parser.rb +57 -0
- data/lib/active_harness/agent/prompt.rb +58 -0
- data/lib/active_harness/agent/providers.rb +54 -0
- data/lib/active_harness/agent.rb +107 -228
- data/lib/active_harness/core/errors.rb +22 -28
- data/lib/active_harness/http/client.rb +8 -19
- data/lib/active_harness/http/streaming_client.rb +60 -0
- data/lib/active_harness/memory/adapter/base.rb +36 -0
- data/lib/active_harness/memory/adapter/file.rb +141 -0
- data/lib/active_harness/memory.rb +212 -0
- data/lib/active_harness/pipeline/step.rb +36 -0
- data/lib/active_harness/pipeline.rb +207 -0
- data/lib/active_harness/providers/PROVIDER_CONTRACT.md +54 -0
- data/lib/active_harness/providers/anthropic.rb +76 -4
- data/lib/active_harness/providers/base.rb +41 -13
- data/lib/active_harness/providers/gemini.rb +61 -0
- data/lib/active_harness/providers/groq.rb +64 -0
- data/lib/active_harness/providers/openai.rb +39 -47
- data/lib/active_harness/providers/openrouter.rb +40 -54
- data/lib/active_harness/railtie.rb +12 -0
- data/lib/active_harness/result.rb +10 -0
- data/lib/active_harness/tribunal.rb +216 -0
- data/lib/active_harness.rb +17 -46
- data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
- data/lib/generators/active_harness/agent/templates/agent.rb.tt +8 -0
- data/lib/generators/active_harness/install/install_generator.rb +54 -0
- data/lib/generators/active_harness/install/templates/agents/test_support_agent.rb +10 -0
- data/lib/generators/active_harness/install/templates/agents/test_support_guard_agent.rb +11 -0
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +105 -0
- data/lib/generators/active_harness/install/templates/memory/test_support_memory.rb +16 -0
- data/lib/generators/active_harness/install/templates/pipelines/test_support_pipeline.rb +31 -0
- data/lib/generators/active_harness/install/templates/prompts/test_support_guard_prompt.rb +9 -0
- data/lib/generators/active_harness/install/templates/prompts/test_support_prompt.rb +5 -0
- data/lib/generators/active_harness/install/templates/tribunals/test_support_guard_tribunal.rb +11 -0
- data/lib/generators/active_harness/memory/memory_generator.rb +16 -0
- data/lib/generators/active_harness/memory/templates/memory.rb.tt +12 -0
- data/lib/generators/active_harness/pipeline/pipeline_generator.rb +16 -0
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +19 -0
- data/lib/generators/active_harness/prompt/prompt_generator.rb +16 -0
- data/lib/generators/active_harness/prompt/templates/prompt.rb.tt +5 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +7 -0
- data/lib/generators/active_harness/tribunal/tribunal_generator.rb +16 -0
- metadata +42 -72
- data/LICENSE +0 -21
- data/README.md +0 -113
- data/lib/active_harness/core/configuration.rb +0 -55
- data/lib/active_harness/core/version.rb +0 -3
- data/lib/active_harness/http/retry_policy.rb +0 -47
- data/lib/active_harness/models/model_request.rb +0 -14
- data/lib/active_harness/models/model_response.rb +0 -13
- data/lib/active_harness/payload.rb +0 -47
- data/lib/active_harness/pipeline/engine.rb +0 -251
- data/lib/active_harness/pipeline/fallback_runner.rb +0 -76
- data/lib/active_harness/pipeline/guard_runner.rb +0 -125
- data/lib/active_harness/pipeline/output_parser.rb +0 -43
- data/lib/active_harness/pipeline/prompt_builder.rb +0 -46
- data/lib/active_harness/pipeline/provider_registry.rb +0 -16
- data/lib/active_harness/prompts/guard_system_prompt.rb +0 -33
- data/lib/active_harness/providers/google.rb +0 -11
- data/lib/active_harness/rate_limit/request_limiter.rb +0 -50
- data/lib/active_harness/rate_limit/risk_holdback.rb +0 -69
- data/lib/active_harness/results/debug_result.rb +0 -19
- data/lib/active_harness/results/input_result.rb +0 -27
- data/lib/active_harness/results/result.rb +0 -55
|
@@ -1,10 +1,82 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
1
3
|
module ActiveHarness
|
|
2
4
|
module Providers
|
|
3
|
-
#
|
|
5
|
+
# Anthropic Claude — native Messages API (not OpenAI-compatible).
|
|
6
|
+
# https://docs.anthropic.com/en/api/messages
|
|
4
7
|
class Anthropic < Base
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
API_URL = URI("https://api.anthropic.com/v1/messages")
|
|
9
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
10
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
11
|
+
|
|
12
|
+
def call(model:, messages:, temperature: 0.7)
|
|
13
|
+
system_msg, chat_messages = extract_system(messages)
|
|
14
|
+
|
|
15
|
+
body = {
|
|
16
|
+
model: model,
|
|
17
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
18
|
+
temperature: temperature,
|
|
19
|
+
messages: chat_messages
|
|
20
|
+
}
|
|
21
|
+
body[:system] = system_msg if system_msg
|
|
22
|
+
|
|
23
|
+
raw = post_json(API_URL,
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type" => "application/json",
|
|
26
|
+
"x-api-key" => api_key,
|
|
27
|
+
"anthropic-version" => ANTHROPIC_VERSION
|
|
28
|
+
},
|
|
29
|
+
body: body
|
|
30
|
+
)
|
|
31
|
+
data = parse!(raw)
|
|
32
|
+
handle_error!(data)
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
content: data.dig("content", 0, "text").to_s.strip,
|
|
36
|
+
provider: :anthropic,
|
|
37
|
+
model: data["model"] || model,
|
|
38
|
+
usage: extract_usage_anthropic(data)
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Anthropic keeps system prompt separate from the messages array.
|
|
45
|
+
def extract_system(messages)
|
|
46
|
+
system = messages.find { |m| m[:role] == "system" || m["role"] == "system" }
|
|
47
|
+
chat = messages.reject { |m| m[:role] == "system" || m["role"] == "system" }
|
|
48
|
+
.map { |m| { role: m[:role] || m["role"], content: m[:content] || m["content"] } }
|
|
49
|
+
|
|
50
|
+
system_text = system && (system[:content] || system["content"])
|
|
51
|
+
[system_text, chat]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def api_key
|
|
55
|
+
key = ENV["ANTHROPIC_API_KEY"].to_s
|
|
56
|
+
raise Errors::InvalidApiKeyError, "ANTHROPIC_API_KEY is not set" if key.empty?
|
|
57
|
+
key
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def handle_error!(data)
|
|
61
|
+
return unless data["error"]
|
|
62
|
+
|
|
63
|
+
msg = data.dig("error", "message").to_s
|
|
64
|
+
type = data.dig("error", "type").to_s
|
|
65
|
+
metadata = data["error"].reject { |k, _| %w[message type].include?(k) }
|
|
66
|
+
metadata = nil if metadata.empty?
|
|
67
|
+
|
|
68
|
+
case type
|
|
69
|
+
when "authentication_error"
|
|
70
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: type, metadata: metadata)
|
|
71
|
+
when "rate_limit_error"
|
|
72
|
+
raise Errors::RateLimitError.new(msg, error_code: type, metadata: metadata)
|
|
73
|
+
when "overloaded_error"
|
|
74
|
+
raise Errors::ProviderUnavailableError.new(msg, error_code: type, metadata: metadata)
|
|
75
|
+
when "api_error"
|
|
76
|
+
raise Errors::ServerError.new(msg, error_code: type, metadata: metadata)
|
|
77
|
+
else
|
|
78
|
+
raise Errors::InvalidRequestError.new(msg, error_code: type, metadata: metadata)
|
|
79
|
+
end
|
|
8
80
|
end
|
|
9
81
|
end
|
|
10
82
|
end
|
|
@@ -1,22 +1,50 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
module ActiveHarness
|
|
2
4
|
module Providers
|
|
3
5
|
class Base
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def call(request)
|
|
7
|
-
raise NotImplementedError, "#{self.class}#call not implemented"
|
|
8
|
-
end
|
|
6
|
+
HTTP = ActiveHarness::Http::Client.new
|
|
7
|
+
STREAMING_HTTP = ActiveHarness::Http::StreamingClient.new
|
|
9
8
|
|
|
10
9
|
private
|
|
11
10
|
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
def post_json(url, headers:, body:, timeout: 30)
|
|
12
|
+
HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def post_json_stream(url, headers:, body:, timeout: 60, on_token:)
|
|
16
|
+
STREAMING_HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout, on_token: on_token)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Normalize OpenAI-compatible usage object to a consistent hash.
|
|
20
|
+
# Returns nil if the response contains no usage data.
|
|
21
|
+
def extract_usage_openai(data)
|
|
22
|
+
u = data["usage"]
|
|
23
|
+
return nil unless u
|
|
24
|
+
{
|
|
25
|
+
input_tokens: u["prompt_tokens"].to_i,
|
|
26
|
+
output_tokens: u["completion_tokens"].to_i,
|
|
27
|
+
total_tokens: u["total_tokens"].to_i
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Normalize Anthropic usage object.
|
|
32
|
+
def extract_usage_anthropic(data)
|
|
33
|
+
u = data["usage"]
|
|
34
|
+
return nil unless u
|
|
35
|
+
input = u["input_tokens"].to_i
|
|
36
|
+
output = u["output_tokens"].to_i
|
|
37
|
+
{
|
|
38
|
+
input_tokens: input,
|
|
39
|
+
output_tokens: output,
|
|
40
|
+
total_tokens: input + output
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse!(raw)
|
|
45
|
+
JSON.parse(raw)
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise Errors::ProviderError, "Invalid JSON response: #{e.message}"
|
|
20
48
|
end
|
|
21
49
|
end
|
|
22
50
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Google Gemini — OpenAI-compatible endpoint (beta).
|
|
6
|
+
# https://ai.google.dev/gemini-api/docs/openai
|
|
7
|
+
class Gemini < Base
|
|
8
|
+
API_URL = URI("https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")
|
|
9
|
+
|
|
10
|
+
def call(model:, messages:, temperature: 0.7)
|
|
11
|
+
raw = post_json(API_URL,
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type" => "application/json",
|
|
14
|
+
"Authorization" => "Bearer #{api_key}"
|
|
15
|
+
},
|
|
16
|
+
body: { model: model, messages: messages, temperature: temperature }
|
|
17
|
+
)
|
|
18
|
+
data = parse!(raw)
|
|
19
|
+
handle_error!(data)
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
23
|
+
provider: :gemini,
|
|
24
|
+
model: data["model"] || model,
|
|
25
|
+
usage: extract_usage_openai(data)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def api_key
|
|
32
|
+
key = ENV["GEMINI_API_KEY"].to_s
|
|
33
|
+
raise Errors::InvalidApiKeyError, "GEMINI_API_KEY is not set" if key.empty?
|
|
34
|
+
key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_error!(data)
|
|
38
|
+
return unless data["error"]
|
|
39
|
+
|
|
40
|
+
msg = data.dig("error", "message").to_s
|
|
41
|
+
code = data.dig("error", "code").to_s
|
|
42
|
+
status = data.dig("error", "status").to_s
|
|
43
|
+
metadata = data["error"].reject { |k, _| %w[message code status].include?(k) }
|
|
44
|
+
metadata = nil if metadata.empty?
|
|
45
|
+
|
|
46
|
+
case status
|
|
47
|
+
when "UNAUTHENTICATED"
|
|
48
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
49
|
+
when "RESOURCE_EXHAUSTED"
|
|
50
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
51
|
+
when "UNAVAILABLE"
|
|
52
|
+
raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
|
|
53
|
+
when "INTERNAL"
|
|
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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Groq — OpenAI-compatible API with fast inference.
|
|
6
|
+
# https://console.groq.com/docs/openai
|
|
7
|
+
class Groq < Base
|
|
8
|
+
API_URL = URI("https://api.groq.com/openai/v1/chat/completions")
|
|
9
|
+
|
|
10
|
+
def call(model:, messages:, temperature: 0.7)
|
|
11
|
+
raw = post_json(API_URL,
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type" => "application/json",
|
|
14
|
+
"Authorization" => "Bearer #{api_key}"
|
|
15
|
+
},
|
|
16
|
+
body: { model: model, messages: messages, temperature: temperature }
|
|
17
|
+
)
|
|
18
|
+
data = parse!(raw)
|
|
19
|
+
handle_error!(data)
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
23
|
+
provider: :groq,
|
|
24
|
+
model: data["model"] || model,
|
|
25
|
+
usage: extract_usage_openai(data)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def api_key
|
|
32
|
+
key = ENV["GROQ_API_KEY"].to_s
|
|
33
|
+
raise Errors::InvalidApiKeyError, "GROQ_API_KEY is not set" if key.empty?
|
|
34
|
+
key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_error!(data)
|
|
38
|
+
return unless data["error"]
|
|
39
|
+
|
|
40
|
+
msg = data.dig("error", "message").to_s
|
|
41
|
+
code = data.dig("error", "code").to_s
|
|
42
|
+
type = data.dig("error", "type").to_s
|
|
43
|
+
metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
|
|
44
|
+
metadata = nil if metadata.empty?
|
|
45
|
+
|
|
46
|
+
case code
|
|
47
|
+
when "invalid_api_key", "unauthorized"
|
|
48
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
49
|
+
when "rate_limit_exceeded"
|
|
50
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
51
|
+
when "500", "502", "503", "504"
|
|
52
|
+
raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
|
|
53
|
+
else
|
|
54
|
+
case type
|
|
55
|
+
when "server_error"
|
|
56
|
+
raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
|
|
57
|
+
else
|
|
58
|
+
raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -1,76 +1,68 @@
|
|
|
1
|
-
require "
|
|
1
|
+
require "uri"
|
|
2
2
|
|
|
3
3
|
module ActiveHarness
|
|
4
4
|
module Providers
|
|
5
5
|
class OpenAI < Base
|
|
6
6
|
API_URL = URI("https://api.openai.com/v1/chat/completions")
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
# @param model [String]
|
|
9
|
+
# @param messages [Array<Hash>] [{role:, content:}, ...]
|
|
10
|
+
# @param temperature [Float]
|
|
11
|
+
# @return [Hash] { content:, provider:, model: }
|
|
12
|
+
def call(model:, messages:, temperature: 0.7)
|
|
13
|
+
raw = post_json(API_URL,
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type" => "application/json",
|
|
16
|
+
"Authorization" => "Bearer #{api_key}"
|
|
17
|
+
},
|
|
18
|
+
body: { model: model, messages: messages, temperature: temperature }
|
|
19
|
+
)
|
|
20
|
+
data = parse!(raw)
|
|
13
21
|
handle_error!(data)
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
build_response(
|
|
19
|
-
content: content,
|
|
23
|
+
{
|
|
24
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
20
25
|
provider: :openai,
|
|
21
|
-
model: data["model"] ||
|
|
22
|
-
usage:
|
|
23
|
-
|
|
24
|
-
)
|
|
26
|
+
model: data["model"] || model,
|
|
27
|
+
usage: extract_usage_openai(data)
|
|
28
|
+
}
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
private
|
|
28
32
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
temperature: request.temperature
|
|
34
|
-
}
|
|
35
|
-
body[:response_format] = { type: "json_object" } if request.response_format == :json
|
|
36
|
-
body.to_json
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def post(body, timeout)
|
|
40
|
-
ActiveHarness.config.http_client.post(
|
|
41
|
-
API_URL,
|
|
42
|
-
headers: {
|
|
43
|
-
"Content-Type" => "application/json",
|
|
44
|
-
"Authorization" => "Bearer #{api_key}"
|
|
45
|
-
},
|
|
46
|
-
body: body,
|
|
47
|
-
timeout: timeout
|
|
48
|
-
)
|
|
33
|
+
def api_key
|
|
34
|
+
key = ENV["OPENAI_API_KEY"].to_s
|
|
35
|
+
raise Errors::InvalidApiKeyError, "OPENAI_API_KEY is not set" if key.empty?
|
|
36
|
+
key
|
|
49
37
|
end
|
|
50
38
|
|
|
51
39
|
def handle_error!(data)
|
|
52
40
|
return unless data["error"]
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
code
|
|
42
|
+
msg = data.dig("error", "message").to_s
|
|
43
|
+
code = data.dig("error", "code").to_s
|
|
44
|
+
type = data.dig("error", "type").to_s
|
|
45
|
+
metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
|
|
46
|
+
metadata = nil if metadata.empty?
|
|
56
47
|
|
|
57
48
|
case code
|
|
58
49
|
when "invalid_api_key", "unauthorized"
|
|
59
|
-
raise Errors::InvalidApiKeyError,
|
|
50
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
60
51
|
when "rate_limit_exceeded"
|
|
61
|
-
raise Errors::RateLimitError,
|
|
52
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
62
53
|
when "content_filter"
|
|
63
|
-
raise Errors::SafetyBlockedError,
|
|
54
|
+
raise Errors::SafetyBlockedError.new(msg, error_code: code, metadata: metadata)
|
|
55
|
+
when "500", "502", "503", "504"
|
|
56
|
+
raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
|
|
64
57
|
else
|
|
65
|
-
|
|
58
|
+
case type
|
|
59
|
+
when "server_error"
|
|
60
|
+
raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
|
|
61
|
+
else
|
|
62
|
+
raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
|
|
63
|
+
end
|
|
66
64
|
end
|
|
67
65
|
end
|
|
68
|
-
|
|
69
|
-
def api_key
|
|
70
|
-
key = ActiveHarness.config.openai_api_key
|
|
71
|
-
raise Errors::InvalidApiKeyError, "OPENAI_API_KEY not configured" if key.nil? || key.empty?
|
|
72
|
-
key
|
|
73
|
-
end
|
|
74
66
|
end
|
|
75
67
|
end
|
|
76
68
|
end
|
|
@@ -1,80 +1,66 @@
|
|
|
1
|
-
require "
|
|
1
|
+
require "uri"
|
|
2
2
|
|
|
3
3
|
module ActiveHarness
|
|
4
4
|
module Providers
|
|
5
|
-
# OpenRouter
|
|
6
|
-
# API is OpenAI-compatible with an extra "HTTP-Referer" header.
|
|
5
|
+
# OpenRouter — OpenAI-compatible API that proxies many models.
|
|
7
6
|
class OpenRouter < Base
|
|
8
7
|
API_URL = URI("https://openrouter.ai/api/v1/chat/completions")
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# @param model [String]
|
|
10
|
+
# @param messages [Array<Hash>] [{role:, content:}, ...]
|
|
11
|
+
# @param temperature [Float]
|
|
12
|
+
# @param stream [Proc, nil] if given, called with each token as it arrives
|
|
13
|
+
# @return [Hash] { content:, provider:, model: }
|
|
14
|
+
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
15
|
+
headers = {
|
|
16
|
+
"Content-Type" => "application/json",
|
|
17
|
+
"Authorization" => "Bearer #{api_key}",
|
|
18
|
+
"HTTP-Referer" => "https://github.com/the-teacher/ActiveHarness"
|
|
19
|
+
}
|
|
20
|
+
body = { model: model, messages: messages, temperature: temperature }
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
if stream
|
|
23
|
+
body[:stream] = true
|
|
24
|
+
content = post_json_stream(API_URL, headers: headers, body: body, on_token: stream)
|
|
25
|
+
return { content: content, provider: :openrouter, model: model }
|
|
26
|
+
end
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
raw = post_json(API_URL, headers: headers, body: body)
|
|
29
|
+
data = parse!(raw)
|
|
30
|
+
handle_error!(data)
|
|
19
31
|
|
|
20
|
-
|
|
21
|
-
content: content,
|
|
32
|
+
{
|
|
33
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
22
34
|
provider: :openrouter,
|
|
23
|
-
model: data["model"] ||
|
|
24
|
-
usage:
|
|
25
|
-
|
|
26
|
-
)
|
|
35
|
+
model: data["model"] || model,
|
|
36
|
+
usage: extract_usage_openai(data)
|
|
37
|
+
}
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
private
|
|
30
41
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
temperature: request.temperature
|
|
36
|
-
}
|
|
37
|
-
body[:response_format] = { type: "json_object" } if request.response_format == :json
|
|
38
|
-
body.to_json
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def post(body, timeout)
|
|
42
|
-
ActiveHarness.config.http_client.post(
|
|
43
|
-
API_URL,
|
|
44
|
-
headers: {
|
|
45
|
-
"Content-Type" => "application/json",
|
|
46
|
-
"Authorization" => "Bearer #{api_key}",
|
|
47
|
-
"HTTP-Referer" => "https://github.com/the-teacher/ActiveHarness"
|
|
48
|
-
},
|
|
49
|
-
body: body,
|
|
50
|
-
timeout: timeout
|
|
51
|
-
)
|
|
42
|
+
def api_key
|
|
43
|
+
key = ENV["OPENROUTER_API_KEY"].to_s
|
|
44
|
+
raise Errors::InvalidApiKeyError, "OPENROUTER_API_KEY is not set" if key.empty?
|
|
45
|
+
key
|
|
52
46
|
end
|
|
53
47
|
|
|
54
48
|
def handle_error!(data)
|
|
55
49
|
return unless data["error"]
|
|
56
50
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
meta = error.reject { |k, _| k == "message" }
|
|
61
|
-
full = meta.empty? ? message : "#{message} | #{meta.inspect}"
|
|
51
|
+
msg = data.dig("error", "message").to_s
|
|
52
|
+
code = data.dig("error", "code").to_s
|
|
53
|
+
metadata = data.dig("error", "metadata")
|
|
62
54
|
|
|
63
55
|
case code
|
|
64
|
-
when "401"
|
|
65
|
-
|
|
66
|
-
when "429"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
56
|
+
when "401" then raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
57
|
+
when "402" then raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
58
|
+
when "429" then raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
59
|
+
when "500", "502",
|
|
60
|
+
"503", "504" then raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
|
|
61
|
+
else raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
|
|
70
62
|
end
|
|
71
63
|
end
|
|
72
|
-
|
|
73
|
-
def api_key
|
|
74
|
-
key = ActiveHarness.config.openrouter_api_key
|
|
75
|
-
raise Errors::InvalidApiKeyError, "OPENROUTER_API_KEY not configured" if key.nil? || key.empty?
|
|
76
|
-
key
|
|
77
|
-
end
|
|
78
64
|
end
|
|
79
65
|
end
|
|
80
66
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module ActiveHarness
|
|
2
|
+
class Railtie < Rails::Railtie
|
|
3
|
+
APP_AI_DIRS = %w[agents prompts tribunals pipelines memory].freeze
|
|
4
|
+
|
|
5
|
+
initializer "active_harness.autoload_paths" do |app|
|
|
6
|
+
APP_AI_DIRS.each do |dir|
|
|
7
|
+
path = Rails.root.join("app", "ai", dir)
|
|
8
|
+
app.config.autoload_paths << path.to_s if path.exist?
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
# Minimal result wrapper returned by Agent#call.
|
|
5
|
+
#
|
|
6
|
+
# output — raw string from the provider
|
|
7
|
+
# parsed — for format :json: a Ruby Hash/Array; for format :text: same as output
|
|
8
|
+
# usage — token counts: { input_tokens:, output_tokens:, total_tokens: } or nil for streaming
|
|
9
|
+
Result = Struct.new(:input, :output, :parsed, :system_prompt, :provider, :model, :temperature, :model_list, :attempts, :execution_time, :usage, keyword_init: true)
|
|
10
|
+
end
|