active_harness 0.3.7 → 0.3.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/goal_check.rb +26 -0
- data/lib/active_harness/agent/state.rb +90 -0
- data/lib/active_harness/agent.rb +734 -0
- data/lib/active_harness/core/errors.rb +11 -0
- data/lib/active_harness/http/client.rb +58 -11
- data/lib/active_harness/http/streaming_client.rb +33 -1
- data/lib/active_harness/pipeline/hooks.rb +1 -1
- data/lib/active_harness/providers/anthropic.rb +57 -8
- data/lib/active_harness/providers/base.rb +6 -0
- data/lib/active_harness/providers/deepseek.rb +7 -4
- data/lib/active_harness/providers/gpustack.rb +7 -4
- data/lib/active_harness/providers/groq.rb +7 -4
- data/lib/active_harness/providers/hetzner.rb +7 -4
- data/lib/active_harness/providers/mistral.rb +7 -4
- data/lib/active_harness/providers/ollama.rb +7 -4
- data/lib/active_harness/providers/ollama_cloud.rb +7 -4
- data/lib/active_harness/providers/openai.rb +7 -4
- data/lib/active_harness/providers/openrouter.rb +7 -4
- data/lib/active_harness/providers/tool_calling.rb +95 -0
- data/lib/active_harness/providers/xai.rb +7 -4
- data/lib/active_harness/railtie.rb +1 -1
- data/lib/active_harness/request/hooks.rb +1 -1
- data/lib/active_harness/request/providers.rb +15 -0
- data/lib/active_harness/request.rb +39 -7
- data/lib/active_harness/result.rb +4 -0
- data/lib/active_harness/tool/path_policy.rb +28 -0
- data/lib/active_harness/tool.rb +348 -0
- data/lib/active_harness/tribunal/hooks.rb +1 -1
- data/lib/active_harness.rb +4 -1
- data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
- data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
- data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
- data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
- data/lib/generators/active_harness/install/install_generator.rb +30 -25
- data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
- data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
- data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
- data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
- data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
- data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
- data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
- data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
- data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
- data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
- data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
- data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
- data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
- data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +40 -0
- data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +43 -0
- data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
- data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
- data/lib/generators/active_harness/tool/templates/tool.rb.tt +22 -0
- data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
- metadata +31 -7
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
module ActiveHarness
|
|
2
|
+
# Matches a client that went away mid-stream: IOError anywhere, plus
|
|
3
|
+
# ActionController::Live::ClientDisconnected when Rails is loaded.
|
|
4
|
+
# Usable in `rescue ActiveHarness::ClientGone`; never raises NameError outside Rails.
|
|
5
|
+
module ClientGone
|
|
6
|
+
def self.===(error)
|
|
7
|
+
return true if IOError === error
|
|
8
|
+
defined?(::ActionController::Live::ClientDisconnected) &&
|
|
9
|
+
::ActionController::Live::ClientDisconnected === error
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
2
13
|
module Errors
|
|
3
14
|
Error = Class.new(StandardError)
|
|
4
15
|
|
|
@@ -4,35 +4,82 @@ module ActiveHarness
|
|
|
4
4
|
module Http
|
|
5
5
|
# Thin Net::HTTP wrapper — no external dependencies.
|
|
6
6
|
class Client
|
|
7
|
-
|
|
7
|
+
METHODS = {
|
|
8
|
+
get: Net::HTTP::Get,
|
|
9
|
+
post: Net::HTTP::Post,
|
|
10
|
+
put: Net::HTTP::Put,
|
|
11
|
+
patch: Net::HTTP::Patch,
|
|
12
|
+
delete: Net::HTTP::Delete
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# @param method [Symbol] :get, :post, :put, :patch or :delete
|
|
16
|
+
# @param url [URI] https (default) or http — TLS follows the URL scheme
|
|
8
17
|
# @param headers [Hash{String => String}]
|
|
9
|
-
# @param body [String]
|
|
18
|
+
# @param body [String, nil] request body (usually JSON-serialized); omit for GET/DELETE
|
|
10
19
|
# @param timeout [Integer] seconds (open + read)
|
|
11
20
|
# @return [String] raw response body; also responds to #http_status
|
|
12
21
|
# (Integer) so providers can classify errors by
|
|
13
22
|
# status without changing the return type
|
|
23
|
+
def request(method, url, headers: {}, body: nil, timeout: 30)
|
|
24
|
+
klass = METHODS[method.to_sym] or raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
res = build_http(url, timeout).request(build_request(klass, url, headers, body))
|
|
28
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
29
|
+
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
30
|
+
rescue => e
|
|
31
|
+
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
with_http_status(res)
|
|
35
|
+
end
|
|
36
|
+
|
|
14
37
|
def post(url, headers:, body:, timeout: 30)
|
|
38
|
+
request(:post, url, headers: headers, body: body, timeout: timeout)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get(url, headers: {}, timeout: 30)
|
|
42
|
+
request(:get, url, headers: headers, timeout: timeout)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def put(url, headers:, body:, timeout: 30)
|
|
46
|
+
request(:put, url, headers: headers, body: body, timeout: timeout)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def patch(url, headers:, body:, timeout: 30)
|
|
50
|
+
request(:patch, url, headers: headers, body: body, timeout: timeout)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def delete(url, headers: {}, timeout: 30)
|
|
54
|
+
request(:delete, url, headers: headers, timeout: timeout)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_http(url, timeout)
|
|
15
60
|
http = Net::HTTP.new(url.host, url.port)
|
|
16
|
-
http.use_ssl =
|
|
61
|
+
http.use_ssl = url.scheme == "https"
|
|
17
62
|
http.open_timeout = timeout
|
|
18
63
|
http.read_timeout = timeout
|
|
64
|
+
http
|
|
65
|
+
end
|
|
19
66
|
|
|
20
|
-
|
|
67
|
+
def build_request(klass, url, headers, body)
|
|
68
|
+
req = klass.new(url)
|
|
21
69
|
headers.each { |k, v| req[k] = v }
|
|
22
|
-
req.body = body
|
|
70
|
+
req.body = body if body
|
|
71
|
+
req
|
|
72
|
+
end
|
|
23
73
|
|
|
24
|
-
|
|
74
|
+
# Returns the response body with an #http_status accessor attached.
|
|
75
|
+
def with_http_status(res)
|
|
25
76
|
body = res.body
|
|
26
77
|
return body if body.nil?
|
|
27
78
|
|
|
28
|
-
body
|
|
79
|
+
body = body.dup
|
|
29
80
|
status = res.code.to_i
|
|
30
81
|
body.define_singleton_method(:http_status) { status }
|
|
31
82
|
body
|
|
32
|
-
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
33
|
-
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
34
|
-
rescue => e
|
|
35
|
-
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
36
83
|
end
|
|
37
84
|
end
|
|
38
85
|
end
|
|
@@ -18,7 +18,7 @@ module ActiveHarness
|
|
|
18
18
|
# @return [Hash] { content: String, usage: Hash|nil }
|
|
19
19
|
def post(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
|
|
20
20
|
http = Net::HTTP.new(url.host, url.port)
|
|
21
|
-
http.use_ssl =
|
|
21
|
+
http.use_ssl = url.scheme == "https"
|
|
22
22
|
http.open_timeout = timeout
|
|
23
23
|
http.read_timeout = timeout
|
|
24
24
|
|
|
@@ -31,6 +31,10 @@ module ActiveHarness
|
|
|
31
31
|
usage = {}
|
|
32
32
|
|
|
33
33
|
http.request(req) do |response|
|
|
34
|
+
# Non-2xx: the body is a plain JSON error, not SSE — read it whole and
|
|
35
|
+
# raise instead of silently returning an empty stream.
|
|
36
|
+
raise_for_status!(response.code.to_i, response.read_body) unless response.is_a?(Net::HTTPSuccess)
|
|
37
|
+
|
|
34
38
|
response.read_body do |chunk|
|
|
35
39
|
buffer += chunk
|
|
36
40
|
while (line_end = buffer.index("\n"))
|
|
@@ -55,12 +59,40 @@ module ActiveHarness
|
|
|
55
59
|
{ content: content, usage: usage.empty? ? nil : usage }
|
|
56
60
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
57
61
|
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
62
|
+
rescue Errors::ProviderError
|
|
63
|
+
raise
|
|
58
64
|
rescue => e
|
|
59
65
|
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
private
|
|
63
69
|
|
|
70
|
+
# Maps an HTTP error status + body to the same error classes the
|
|
71
|
+
# non-streaming providers raise. The message is pulled from the common
|
|
72
|
+
# error shapes ({error:{message}}, {message}, {detail}), else the raw body.
|
|
73
|
+
def raise_for_status!(status, body)
|
|
74
|
+
msg = error_message(body)
|
|
75
|
+
code = status.to_s
|
|
76
|
+
|
|
77
|
+
case status
|
|
78
|
+
when 401, 403 then raise Errors::InvalidApiKeyError.new(msg, error_code: code)
|
|
79
|
+
when 429 then raise Errors::RateLimitError.new(msg, error_code: code)
|
|
80
|
+
when 503 then raise Errors::ProviderUnavailableError.new(msg, error_code: code)
|
|
81
|
+
when 500..599 then raise Errors::ServerError.new(msg, error_code: code)
|
|
82
|
+
else raise Errors::InvalidRequestError.new(msg, error_code: code)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def error_message(body)
|
|
87
|
+
data = JSON.parse(body.to_s)
|
|
88
|
+
msg = data.dig("error", "message") if data["error"].is_a?(Hash)
|
|
89
|
+
msg ||= data["error"] if data["error"].is_a?(String)
|
|
90
|
+
msg ||= data["message"] || data["detail"]
|
|
91
|
+
(msg.is_a?(String) ? msg : data.to_json)
|
|
92
|
+
rescue JSON::ParserError, NoMethodError, TypeError
|
|
93
|
+
body.to_s.strip.empty? ? "HTTP error with empty body" : body.to_s.strip[0, 500]
|
|
94
|
+
end
|
|
95
|
+
|
|
64
96
|
def default_chunk(parsed)
|
|
65
97
|
token = parsed.dig("choices", 0, "delta", "content")
|
|
66
98
|
raw_u = parsed["usage"]
|
|
@@ -63,7 +63,7 @@ module ActiveHarness
|
|
|
63
63
|
def fire(event, step_name, data, config)
|
|
64
64
|
run_hooks(config[:hooks], event, step_name, data)
|
|
65
65
|
@stream&.call(:pipeline, event, step_name, data)
|
|
66
|
-
rescue
|
|
66
|
+
rescue ClientGone
|
|
67
67
|
nil
|
|
68
68
|
end
|
|
69
69
|
|
|
@@ -8,7 +8,11 @@ module ActiveHarness
|
|
|
8
8
|
ANTHROPIC_VERSION = "2023-06-01"
|
|
9
9
|
DEFAULT_MAX_TOKENS = 1024
|
|
10
10
|
|
|
11
|
-
def
|
|
11
|
+
def supports_tools?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
12
16
|
system_msg, chat_messages = extract_system(messages)
|
|
13
17
|
|
|
14
18
|
body = {
|
|
@@ -18,6 +22,7 @@ module ActiveHarness
|
|
|
18
22
|
messages: chat_messages
|
|
19
23
|
}
|
|
20
24
|
body[:system] = system_msg if system_msg
|
|
25
|
+
body[:tools] = anthropic_tools(tools) if tools && !tools.empty?
|
|
21
26
|
|
|
22
27
|
headers = {
|
|
23
28
|
"Content-Type" => "application/json",
|
|
@@ -32,30 +37,74 @@ module ActiveHarness
|
|
|
32
37
|
stream: stream,
|
|
33
38
|
provider: :anthropic,
|
|
34
39
|
model: model
|
|
35
|
-
) if stream
|
|
40
|
+
) if stream && !(tools && !tools.empty?)
|
|
36
41
|
|
|
37
42
|
raw = post_json(URI(config.anthropic_api_url), headers: headers, body: body)
|
|
38
43
|
data = parse!(raw)
|
|
39
44
|
handle_error!(data)
|
|
40
45
|
|
|
41
46
|
{
|
|
42
|
-
content: data
|
|
47
|
+
content: Array(data["content"]).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join.strip,
|
|
43
48
|
provider: :anthropic,
|
|
44
49
|
model: data["model"] || model,
|
|
45
|
-
usage: extract_usage_anthropic(data)
|
|
50
|
+
usage: extract_usage_anthropic(data),
|
|
51
|
+
**anthropic_tool_calls_entry(data)
|
|
46
52
|
}
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
private
|
|
50
56
|
|
|
51
57
|
# Anthropic keeps system prompt separate from the messages array.
|
|
58
|
+
# Also maps the neutral tool-calling message shape to Anthropic content blocks.
|
|
52
59
|
def extract_system(messages)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
role_of = ->(m) { (m[:role] || m["role"]).to_s }
|
|
61
|
+
system = messages.find { |m| role_of.call(m) == "system" }
|
|
62
|
+
chat = messages.reject { |m| role_of.call(m) == "system" }.map { |m| anthropic_message(m) }
|
|
56
63
|
|
|
57
64
|
system_text = system && (system[:content] || system["content"])
|
|
58
|
-
[system_text, chat]
|
|
65
|
+
[system_text, merge_tool_results(chat)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def anthropic_message(m)
|
|
69
|
+
role = (m[:role] || m["role"]).to_s
|
|
70
|
+
content = m[:content] || m["content"]
|
|
71
|
+
calls = m[:tool_calls] || m["tool_calls"]
|
|
72
|
+
|
|
73
|
+
if role == "assistant" && calls && !calls.empty?
|
|
74
|
+
blocks = []
|
|
75
|
+
blocks << { type: "text", text: content.to_s } unless content.to_s.empty?
|
|
76
|
+
calls.each { |c| blocks << { type: "tool_use", id: c[:id], name: c[:name], input: c[:arguments] || {} } }
|
|
77
|
+
{ role: "assistant", content: blocks }
|
|
78
|
+
elsif role == "tool"
|
|
79
|
+
{ role: "user", content: [{ type: "tool_result", tool_use_id: m[:tool_call_id] || m["tool_call_id"], content: content.to_s }] }
|
|
80
|
+
else
|
|
81
|
+
{ role: role, content: content }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Consecutive tool results must live in one user message.
|
|
86
|
+
def merge_tool_results(chat)
|
|
87
|
+
chat.each_with_object([]) do |m, out|
|
|
88
|
+
prev = out.last
|
|
89
|
+
if prev && prev[:role] == "user" && m[:role] == "user" &&
|
|
90
|
+
prev[:content].is_a?(Array) && m[:content].is_a?(Array) &&
|
|
91
|
+
prev[:content].all? { |b| b[:type] == "tool_result" } && m[:content].all? { |b| b[:type] == "tool_result" }
|
|
92
|
+
prev[:content].concat(m[:content])
|
|
93
|
+
else
|
|
94
|
+
out << m
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def anthropic_tools(tools)
|
|
100
|
+
Array(tools).map { |t| { name: t[:name], description: t[:description].to_s, input_schema: t[:parameters] } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def anthropic_tool_calls_entry(data)
|
|
104
|
+
calls = Array(data["content"]).select { |b| b["type"] == "tool_use" }
|
|
105
|
+
return {} if calls.empty?
|
|
106
|
+
|
|
107
|
+
{ tool_calls: calls.map { |b| { id: b["id"], name: b["name"], arguments: b["input"] || {}, raw_arguments: JSON.generate(b["input"] || {}) } } }
|
|
59
108
|
end
|
|
60
109
|
|
|
61
110
|
def api_key
|
|
@@ -6,6 +6,12 @@ module ActiveHarness
|
|
|
6
6
|
HTTP = ActiveHarness::Http::Client.new
|
|
7
7
|
STREAMING_HTTP = ActiveHarness::Http::StreamingClient.new
|
|
8
8
|
|
|
9
|
+
# Providers that can send `tools:` and return `tool_calls:` override this
|
|
10
|
+
# (see Providers::ToolCalling). Everything else is rejected by Request.
|
|
11
|
+
def supports_tools?
|
|
12
|
+
false
|
|
13
|
+
end
|
|
14
|
+
|
|
9
15
|
private
|
|
10
16
|
|
|
11
17
|
def config
|
|
@@ -5,11 +5,14 @@ module ActiveHarness
|
|
|
5
5
|
# DeepSeek — OpenAI-compatible API.
|
|
6
6
|
# https://platform.deepseek.com/api-docs
|
|
7
7
|
class DeepSeek < Base
|
|
8
|
-
|
|
8
|
+
include ToolCalling
|
|
9
|
+
|
|
10
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
9
11
|
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
10
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
12
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
13
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
11
14
|
|
|
12
|
-
return call_streaming(url: config.deepseek_api_url, headers: headers, body: body, stream: stream, provider: :deepseek, model: model) if stream
|
|
15
|
+
return call_streaming(url: config.deepseek_api_url, headers: headers, body: body, stream: stream, provider: :deepseek, model: model) if stream && !tools?(tools)
|
|
13
16
|
|
|
14
17
|
raw = post_json(URI(config.deepseek_api_url), headers: headers, body: body)
|
|
15
18
|
data = parse!(raw)
|
|
@@ -19,7 +22,7 @@ module ActiveHarness
|
|
|
19
22
|
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
20
23
|
provider: :deepseek,
|
|
21
24
|
model: data["model"] || model,
|
|
22
|
-
usage: extract_usage_openai(data)
|
|
25
|
+
usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
|
|
23
26
|
}
|
|
24
27
|
end
|
|
25
28
|
|
|
@@ -13,20 +13,23 @@ module ActiveHarness
|
|
|
13
13
|
# use provider: :gpustack, model: "Qwen/Qwen2.5-7B-Instruct-GGUF"
|
|
14
14
|
# end
|
|
15
15
|
class GPUStack < Base
|
|
16
|
-
|
|
16
|
+
include ToolCalling
|
|
17
|
+
|
|
18
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
17
19
|
url = "#{api_base}/v1/chat/completions"
|
|
18
20
|
headers = { "Content-Type" => "application/json" }
|
|
19
21
|
key = api_key
|
|
20
22
|
headers["Authorization"] = "Bearer #{key}" if key
|
|
21
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
23
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
24
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
22
25
|
|
|
23
|
-
return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :gpustack, model: model) if stream
|
|
26
|
+
return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :gpustack, model: model) if stream && !tools?(tools)
|
|
24
27
|
|
|
25
28
|
raw = post_json(URI(url), headers: headers, body: body)
|
|
26
29
|
data = parse!(raw)
|
|
27
30
|
handle_error!(data)
|
|
28
31
|
|
|
29
|
-
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :gpustack, model: data["model"] || model, usage: extract_usage_openai(data) }
|
|
32
|
+
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :gpustack, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
private
|
|
@@ -5,17 +5,20 @@ 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
|
-
|
|
8
|
+
include ToolCalling
|
|
9
|
+
|
|
10
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
9
11
|
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
10
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
12
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
13
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
11
14
|
|
|
12
|
-
return call_streaming(url: config.groq_api_url, headers: headers, body: body, stream: stream, provider: :groq, model: model) if stream
|
|
15
|
+
return call_streaming(url: config.groq_api_url, headers: headers, body: body, stream: stream, provider: :groq, model: model) if stream && !tools?(tools)
|
|
13
16
|
|
|
14
17
|
raw = post_json(URI(config.groq_api_url), headers: headers, body: body)
|
|
15
18
|
data = parse!(raw)
|
|
16
19
|
handle_error!(data)
|
|
17
20
|
|
|
18
|
-
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :groq, model: data["model"] || model, usage: extract_usage_openai(data) }
|
|
21
|
+
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :groq, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
private
|
|
@@ -13,11 +13,14 @@ module ActiveHarness
|
|
|
13
13
|
# use provider: :hetzner, model: "meta-llama/Llama-3.3-70B-Instruct"
|
|
14
14
|
# end
|
|
15
15
|
class Hetzner < Base
|
|
16
|
-
|
|
16
|
+
include ToolCalling
|
|
17
|
+
|
|
18
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
17
19
|
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
18
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
20
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
21
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
19
22
|
|
|
20
|
-
return call_streaming(url: config.hetzner_api_url, headers: headers, body: body, stream: stream, provider: :hetzner, model: model) if stream
|
|
23
|
+
return call_streaming(url: config.hetzner_api_url, headers: headers, body: body, stream: stream, provider: :hetzner, model: model) if stream && !tools?(tools)
|
|
21
24
|
|
|
22
25
|
raw = post_json(URI(config.hetzner_api_url), headers: headers, body: body)
|
|
23
26
|
data = parse!(raw)
|
|
@@ -27,7 +30,7 @@ module ActiveHarness
|
|
|
27
30
|
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
28
31
|
provider: :hetzner,
|
|
29
32
|
model: data["model"] || model,
|
|
30
|
-
usage: extract_usage_openai(data)
|
|
33
|
+
usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
|
|
31
34
|
}
|
|
32
35
|
end
|
|
33
36
|
|
|
@@ -5,11 +5,14 @@ module ActiveHarness
|
|
|
5
5
|
# Mistral AI — OpenAI-compatible API.
|
|
6
6
|
# https://docs.mistral.ai/api
|
|
7
7
|
class Mistral < Base
|
|
8
|
-
|
|
8
|
+
include ToolCalling
|
|
9
|
+
|
|
10
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
9
11
|
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
10
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
12
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
13
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
11
14
|
|
|
12
|
-
return call_streaming(url: config.mistral_api_url, headers: headers, body: body, stream: stream, provider: :mistral, model: model) if stream
|
|
15
|
+
return call_streaming(url: config.mistral_api_url, headers: headers, body: body, stream: stream, provider: :mistral, model: model) if stream && !tools?(tools)
|
|
13
16
|
|
|
14
17
|
raw = post_json(URI(config.mistral_api_url), headers: headers, body: body)
|
|
15
18
|
data = parse!(raw)
|
|
@@ -19,7 +22,7 @@ module ActiveHarness
|
|
|
19
22
|
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
20
23
|
provider: :mistral,
|
|
21
24
|
model: data["model"] || model,
|
|
22
|
-
usage: extract_usage_openai(data)
|
|
25
|
+
usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
|
|
23
26
|
}
|
|
24
27
|
end
|
|
25
28
|
|
|
@@ -13,20 +13,23 @@ module ActiveHarness
|
|
|
13
13
|
# use provider: :ollama, model: "llama3.2"
|
|
14
14
|
# end
|
|
15
15
|
class Ollama < Base
|
|
16
|
-
|
|
16
|
+
include ToolCalling
|
|
17
|
+
|
|
18
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
17
19
|
url = "#{api_base}/v1/chat/completions"
|
|
18
20
|
headers = { "Content-Type" => "application/json" }
|
|
19
21
|
key = api_key
|
|
20
22
|
headers["Authorization"] = "Bearer #{key}" if key
|
|
21
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
23
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
24
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
22
25
|
|
|
23
|
-
return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :ollama, model: model) if stream
|
|
26
|
+
return call_streaming(url: url, headers: headers, body: body, stream: stream, provider: :ollama, model: model) if stream && !tools?(tools)
|
|
24
27
|
|
|
25
28
|
raw = post_json(URI(url), headers: headers, body: body)
|
|
26
29
|
data = parse!(raw)
|
|
27
30
|
handle_error!(data)
|
|
28
31
|
|
|
29
|
-
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :ollama, model: data["model"] || model, usage: extract_usage_openai(data) }
|
|
32
|
+
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :ollama, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
private
|
|
@@ -12,11 +12,14 @@ module ActiveHarness
|
|
|
12
12
|
# use provider: :ollama_cloud, model: "gpt-oss:120b"
|
|
13
13
|
# end
|
|
14
14
|
class OllamaCloud < Base
|
|
15
|
-
|
|
15
|
+
include ToolCalling
|
|
16
|
+
|
|
17
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
16
18
|
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
17
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
19
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
20
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
18
21
|
|
|
19
|
-
return call_streaming(url: config.ollama_cloud_api_url, headers: headers, body: body, stream: stream, provider: :ollama_cloud, model: model) if stream
|
|
22
|
+
return call_streaming(url: config.ollama_cloud_api_url, headers: headers, body: body, stream: stream, provider: :ollama_cloud, model: model) if stream && !tools?(tools)
|
|
20
23
|
|
|
21
24
|
raw = post_json(URI(config.ollama_cloud_api_url), headers: headers, body: body)
|
|
22
25
|
data = parse!(raw)
|
|
@@ -26,7 +29,7 @@ module ActiveHarness
|
|
|
26
29
|
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
27
30
|
provider: :ollama_cloud,
|
|
28
31
|
model: data["model"] || model,
|
|
29
|
-
usage: extract_usage_openai(data)
|
|
32
|
+
usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
|
|
30
33
|
}
|
|
31
34
|
end
|
|
32
35
|
|
|
@@ -3,21 +3,24 @@ require "uri"
|
|
|
3
3
|
module ActiveHarness
|
|
4
4
|
module Providers
|
|
5
5
|
class OpenAI < Base
|
|
6
|
+
include ToolCalling
|
|
7
|
+
|
|
6
8
|
# @param model [String]
|
|
7
9
|
# @param messages [Array<Hash>] [{role:, content:}, ...]
|
|
8
10
|
# @param temperature [Float]
|
|
9
11
|
# @return [Hash] { content:, provider:, model: }
|
|
10
|
-
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
12
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
11
13
|
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
12
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
14
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
15
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
13
16
|
|
|
14
|
-
return call_streaming(url: config.openai_api_url, headers: headers, body: body, stream: stream, provider: :openai, model: model) if stream
|
|
17
|
+
return call_streaming(url: config.openai_api_url, headers: headers, body: body, stream: stream, provider: :openai, model: model) if stream && !tools?(tools)
|
|
15
18
|
|
|
16
19
|
raw = post_json(URI(config.openai_api_url), headers: headers, body: body)
|
|
17
20
|
data = parse!(raw)
|
|
18
21
|
handle_error!(data)
|
|
19
22
|
|
|
20
|
-
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :openai, model: data["model"] || model, usage: extract_usage_openai(data) }
|
|
23
|
+
{ content: data.dig("choices", 0, "message", "content").to_s.strip, provider: :openai, model: data["model"] || model, usage: extract_usage_openai(data), **openai_tool_calls_entry(data) }
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
private
|
|
@@ -4,20 +4,23 @@ module ActiveHarness
|
|
|
4
4
|
module Providers
|
|
5
5
|
# OpenRouter — OpenAI-compatible API that proxies many models.
|
|
6
6
|
class OpenRouter < Base
|
|
7
|
+
include ToolCalling
|
|
8
|
+
|
|
7
9
|
# @param model [String]
|
|
8
10
|
# @param messages [Array<Hash>] [{role:, content:}, ...]
|
|
9
11
|
# @param temperature [Float]
|
|
10
12
|
# @param stream [Proc, nil] if given, called with each token as it arrives
|
|
11
13
|
# @return [Hash] { content:, provider:, model: }
|
|
12
|
-
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
14
|
+
def call(model:, messages:, temperature: 0.7, stream: nil, tools: nil)
|
|
13
15
|
headers = {
|
|
14
16
|
"Content-Type" => "application/json",
|
|
15
17
|
"Authorization" => "Bearer #{api_key}",
|
|
16
18
|
"HTTP-Referer" => config.openrouter_http_referer
|
|
17
19
|
}
|
|
18
|
-
body = { model: model, messages: messages, temperature: temperature }
|
|
20
|
+
body = { model: model, messages: openai_messages(messages), temperature: temperature }
|
|
21
|
+
body[:tools] = openai_tools(tools) if tools?(tools)
|
|
19
22
|
|
|
20
|
-
return call_streaming(url: config.openrouter_api_url, headers: headers, body: body, stream: stream, provider: :openrouter, model: model) if stream
|
|
23
|
+
return call_streaming(url: config.openrouter_api_url, headers: headers, body: body, stream: stream, provider: :openrouter, model: model) if stream && !tools?(tools)
|
|
21
24
|
|
|
22
25
|
raw = post_json(URI(config.openrouter_api_url), headers: headers, body: body)
|
|
23
26
|
data = parse!(raw)
|
|
@@ -27,7 +30,7 @@ module ActiveHarness
|
|
|
27
30
|
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
28
31
|
provider: :openrouter,
|
|
29
32
|
model: data["model"] || model,
|
|
30
|
-
usage: extract_usage_openai(data)
|
|
33
|
+
usage: extract_usage_openai(data), **openai_tool_calls_entry(data)
|
|
31
34
|
}
|
|
32
35
|
end
|
|
33
36
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Tool-calling support shared by OpenAI-compatible providers.
|
|
6
|
+
#
|
|
7
|
+
# ActiveHarness speaks to providers in a neutral shape:
|
|
8
|
+
#
|
|
9
|
+
# tools: [{ name: "os__ls", description: "...", parameters: { type: "object", ... } }]
|
|
10
|
+
# messages: [{ role: :user, content: "..." },
|
|
11
|
+
# { role: :assistant, content: "", tool_calls: [{ id:, name:, arguments: {} }] },
|
|
12
|
+
# { role: :tool, tool_call_id: "...", name: "os__ls", content: "..." }]
|
|
13
|
+
#
|
|
14
|
+
# and receives tool calls back as [{ id:, name:, arguments: Hash|nil, raw_arguments: String }].
|
|
15
|
+
# This module maps that shape to and from the OpenAI wire format.
|
|
16
|
+
module ToolCalling
|
|
17
|
+
def supports_tools?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def tools?(tools)
|
|
24
|
+
!tools.nil? && !tools.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def openai_tools(tools)
|
|
28
|
+
Array(tools).map do |t|
|
|
29
|
+
{
|
|
30
|
+
type: "function",
|
|
31
|
+
function: { name: t[:name], description: t[:description].to_s, parameters: t[:parameters] }
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def openai_messages(messages)
|
|
37
|
+
messages.map { |m| openai_message(m) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def openai_message(message)
|
|
41
|
+
role = (message[:role] || message["role"]).to_s
|
|
42
|
+
calls = message[:tool_calls] || message["tool_calls"]
|
|
43
|
+
|
|
44
|
+
if role == "assistant" && calls && !calls.empty?
|
|
45
|
+
content = message[:content] || message["content"]
|
|
46
|
+
{
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content: content.to_s.empty? ? nil : content,
|
|
49
|
+
tool_calls: calls.map do |c|
|
|
50
|
+
{
|
|
51
|
+
id: c[:id],
|
|
52
|
+
type: "function",
|
|
53
|
+
function: { name: c[:name], arguments: JSON.generate(c[:arguments] || {}) }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
}
|
|
57
|
+
elsif role == "tool"
|
|
58
|
+
{
|
|
59
|
+
role: "tool",
|
|
60
|
+
tool_call_id: message[:tool_call_id] || message["tool_call_id"],
|
|
61
|
+
content: (message[:content] || message["content"]).to_s
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
message
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# { tool_calls: [...] } when the response contains tool calls, {} otherwise.
|
|
69
|
+
# Meant to be splatted into the provider's response hash.
|
|
70
|
+
def openai_tool_calls_entry(data)
|
|
71
|
+
raw = data.dig("choices", 0, "message", "tool_calls")
|
|
72
|
+
return {} if raw.nil? || raw.empty?
|
|
73
|
+
|
|
74
|
+
{ tool_calls: raw.map { |c| parse_openai_tool_call(c) } }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_openai_tool_call(call)
|
|
78
|
+
raw_args = call.dig("function", "arguments")
|
|
79
|
+
args =
|
|
80
|
+
case raw_args
|
|
81
|
+
when Hash then raw_args
|
|
82
|
+
when String then raw_args.strip.empty? ? {} : (JSON.parse(raw_args) rescue nil)
|
|
83
|
+
else {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
id: call["id"],
|
|
88
|
+
name: call.dig("function", "name"),
|
|
89
|
+
arguments: args.is_a?(Hash) ? args : nil,
|
|
90
|
+
raw_arguments: raw_args.is_a?(String) ? raw_args : raw_args.to_s
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|