active_harness 0.3.6 → 0.3.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.
- checksums.yaml +4 -4
- data/lib/active_harness/configuration.rb +9 -0
- data/lib/active_harness/http/client.rb +67 -11
- data/lib/active_harness/http/streaming_client.rb +33 -1
- data/lib/active_harness/providers/cohere.rb +101 -0
- data/lib/active_harness/providers/vercel.rb +16 -3
- data/lib/active_harness/request/providers.rb +1 -0
- data/lib/active_harness.rb +2 -1
- data/lib/generators/active_harness/install/templates/initializers/active_harness.rb +6 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c6df0d88c07312b2546e83f5e69c58d5b3628e4ff31b0885e10a72d032a5a5e
|
|
4
|
+
data.tar.gz: f2427084db1992f1c0cdcd44c7fe6518cfaa0d348f1d3720ce9fcf78e40783c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39575f2575e8dd5d1dc90a512f1d550512d90b57d37d182c10055901c5f15f6bb4a7d425c5a362acacf0be248c237707d0e1362d71d912b2eb449340c20a9ef9
|
|
7
|
+
data.tar.gz: 41f8adbbb47d9a0e2a17505f470b8a77ffac7f275eb530ee87f3a5f60c3eaa707a6f2610f16f63fc1a73872d75cb02cfad81a0251dd831b685d02fce0f5bad85
|
|
@@ -105,6 +105,12 @@ module ActiveHarness
|
|
|
105
105
|
attr_accessor :vercel_api_key
|
|
106
106
|
attr_accessor :vercel_api_url
|
|
107
107
|
|
|
108
|
+
# -------------------------------------------------------------------------
|
|
109
|
+
# Cohere (native v2 Chat API)
|
|
110
|
+
# -------------------------------------------------------------------------
|
|
111
|
+
attr_accessor :cohere_api_key
|
|
112
|
+
attr_accessor :cohere_api_url
|
|
113
|
+
|
|
108
114
|
# -------------------------------------------------------------------------
|
|
109
115
|
# TypeSafe — direct Jev (System One) API; also any Jev-compatible server
|
|
110
116
|
# -------------------------------------------------------------------------
|
|
@@ -210,6 +216,9 @@ module ActiveHarness
|
|
|
210
216
|
@vercel_api_key = ENV["VERCEL_API_KEY"]
|
|
211
217
|
@vercel_api_url = "https://ai-gateway.vercel.sh/typesafe/v1/systemone"
|
|
212
218
|
|
|
219
|
+
@cohere_api_key = ENV["COHERE_API_KEY"]
|
|
220
|
+
@cohere_api_url = "https://api.cohere.com/v2/chat"
|
|
221
|
+
|
|
213
222
|
@typesafe_api_key = ENV["TYPESAFE_API_KEY"]
|
|
214
223
|
@typesafe_api_url = "https://api.typesafe.ai/v1/systemone"
|
|
215
224
|
|
|
@@ -4,26 +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
|
-
# @return [String] raw response body
|
|
20
|
+
# @return [String] raw response body; also responds to #http_status
|
|
21
|
+
# (Integer) so providers can classify errors by
|
|
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
|
+
|
|
12
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)
|
|
13
60
|
http = Net::HTTP.new(url.host, url.port)
|
|
14
|
-
http.use_ssl =
|
|
61
|
+
http.use_ssl = url.scheme == "https"
|
|
15
62
|
http.open_timeout = timeout
|
|
16
63
|
http.read_timeout = timeout
|
|
64
|
+
http
|
|
65
|
+
end
|
|
17
66
|
|
|
18
|
-
|
|
67
|
+
def build_request(klass, url, headers, body)
|
|
68
|
+
req = klass.new(url)
|
|
19
69
|
headers.each { |k, v| req[k] = v }
|
|
20
|
-
req.body = body
|
|
70
|
+
req.body = body if body
|
|
71
|
+
req
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the response body with an #http_status accessor attached.
|
|
75
|
+
def with_http_status(res)
|
|
76
|
+
body = res.body
|
|
77
|
+
return body if body.nil?
|
|
21
78
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
79
|
+
body = body.dup
|
|
80
|
+
status = res.code.to_i
|
|
81
|
+
body.define_singleton_method(:http_status) { status }
|
|
82
|
+
body
|
|
27
83
|
end
|
|
28
84
|
end
|
|
29
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"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Cohere — native v2 Chat API (not OpenAI-compatible).
|
|
6
|
+
# https://docs.cohere.com/reference/chat
|
|
7
|
+
#
|
|
8
|
+
# Text chat only: tools, citations, documents, rerank and embeddings are not
|
|
9
|
+
# supported. Thinking blocks returned by reasoning models are discarded —
|
|
10
|
+
# only `text` blocks make it into the result.
|
|
11
|
+
#
|
|
12
|
+
# Example:
|
|
13
|
+
# model do
|
|
14
|
+
# use provider: :cohere, model: "command-a-03-2025"
|
|
15
|
+
# end
|
|
16
|
+
class Cohere < Base
|
|
17
|
+
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
18
|
+
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
19
|
+
body = { model: model, messages: messages, temperature: temperature }
|
|
20
|
+
|
|
21
|
+
return call_streaming(url: config.cohere_api_url, headers: headers, body: body, stream: stream, provider: :cohere, model: model) if stream
|
|
22
|
+
|
|
23
|
+
raw = post_json(URI(config.cohere_api_url), headers: headers, body: body)
|
|
24
|
+
data = parse!(raw)
|
|
25
|
+
handle_error!(data, status: raw.respond_to?(:http_status) ? raw.http_status : nil)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
content: extract_text(data),
|
|
29
|
+
provider: :cohere,
|
|
30
|
+
model: model,
|
|
31
|
+
usage: extract_usage_cohere(data["usage"])
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def api_key
|
|
38
|
+
key = config.cohere_api_key.to_s
|
|
39
|
+
raise Errors::InvalidApiKeyError, "cohere_api_key is not configured" if key.empty?
|
|
40
|
+
key
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# message.content is an array of typed blocks ({type: "text"|"thinking", ...});
|
|
44
|
+
# keep only the text.
|
|
45
|
+
def extract_text(data)
|
|
46
|
+
Array(data.dig("message", "content"))
|
|
47
|
+
.select { |block| block["type"] == "text" }
|
|
48
|
+
.map { |block| block["text"].to_s }
|
|
49
|
+
.join
|
|
50
|
+
.strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `tokens` is what the model processed, `billed_units` what Cohere charges
|
|
54
|
+
# for (it does not bill its own preamble). Prefer `tokens`, like ruby_llm.
|
|
55
|
+
def extract_usage_cohere(usage)
|
|
56
|
+
return nil unless usage
|
|
57
|
+
|
|
58
|
+
tokens = usage["tokens"] || {}
|
|
59
|
+
billed = usage["billed_units"] || {}
|
|
60
|
+
input = (tokens["input_tokens"] || billed["input_tokens"]).to_i
|
|
61
|
+
output = (tokens["output_tokens"] || billed["output_tokens"]).to_i
|
|
62
|
+
{ input_tokens: input, output_tokens: output, total_tokens: input + output }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Cohere errors are flat: { "message": "..." } (validation errors may add
|
|
66
|
+
# an "id"). Success bodies have no "message" string, only a message Hash.
|
|
67
|
+
def handle_error!(data, status: nil)
|
|
68
|
+
return unless data["message"].is_a?(String)
|
|
69
|
+
return if status && status < 400
|
|
70
|
+
|
|
71
|
+
msg = data["message"]
|
|
72
|
+
code = status ? status.to_s : nil
|
|
73
|
+
meta = data["id"] ? { id: data["id"] } : nil
|
|
74
|
+
|
|
75
|
+
case status
|
|
76
|
+
when 401, 403 then raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: meta)
|
|
77
|
+
when 429 then raise Errors::RateLimitError.new(msg, error_code: code, metadata: meta)
|
|
78
|
+
when 503 then raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: meta)
|
|
79
|
+
when 500..599 then raise Errors::ServerError.new(msg, error_code: code, metadata: meta)
|
|
80
|
+
else raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: meta)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Cohere streaming takes a plain `stream: true` — no stream_options.
|
|
85
|
+
def prepare_streaming_body(body)
|
|
86
|
+
body.merge(stream: true)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Cohere SSE events:
|
|
90
|
+
# content-delta → delta.message.content.text (or .thinking, ignored)
|
|
91
|
+
# message-end → delta.usage with token counts
|
|
92
|
+
def build_streaming_chunk(parsed)
|
|
93
|
+
token = parsed.dig("delta", "message", "content", "text") if parsed["type"] == "content-delta"
|
|
94
|
+
usage = extract_usage_cohere(parsed.dig("delta", "usage")) if parsed["type"] == "message-end"
|
|
95
|
+
usage = usage&.reject { |k, _| k == :total_tokens }
|
|
96
|
+
|
|
97
|
+
{ token: token, usage: usage }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -60,7 +60,7 @@ module ActiveHarness
|
|
|
60
60
|
|
|
61
61
|
raw = post_json(URI(endpoint_url), headers: headers, body: body)
|
|
62
62
|
data = parse!(raw)
|
|
63
|
-
handle_error!(data)
|
|
63
|
+
handle_error!(data, status: raw.respond_to?(:http_status) ? raw.http_status : nil)
|
|
64
64
|
|
|
65
65
|
{
|
|
66
66
|
content: JSON.pretty_generate(data["answers"]),
|
|
@@ -114,15 +114,27 @@ module ActiveHarness
|
|
|
114
114
|
end
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Prefer the HTTP status when we have it; fall back to sniffing the
|
|
118
|
+
# message only when we don't (e.g. a custom HTTP client without #http_status).
|
|
119
|
+
def detail_error_type(msg, status)
|
|
120
|
+
case status
|
|
121
|
+
when 401, 403 then "unauthorized"
|
|
122
|
+
when 429 then "rate_limit"
|
|
123
|
+
when 500..599 then "server_error"
|
|
124
|
+
when nil then msg =~ /api key|unauthori|authenticat|invalid token|credentials/i ? "unauthorized" : "invalid_request"
|
|
125
|
+
else "invalid_request"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
117
129
|
# Error shapes seen in practice:
|
|
118
130
|
# - TypeSafe direct API: { "detail": ... } (see detail_message)
|
|
119
131
|
# - TypeSafe request-validation errors: { "message": "...", "error_type": "..." }
|
|
120
132
|
# - AI Gateway account/billing errors (OpenAI-style, nested): { "error": { "message": "...", "type": "..." } }
|
|
121
|
-
def handle_error!(data)
|
|
133
|
+
def handle_error!(data, status: nil)
|
|
122
134
|
msg, type =
|
|
123
135
|
if data.key?("detail")
|
|
124
136
|
msg = detail_message(data["detail"])
|
|
125
|
-
[msg, msg
|
|
137
|
+
[msg, detail_error_type(msg, status)]
|
|
126
138
|
elsif data["message"] && data["error_type"]
|
|
127
139
|
[data["message"].to_s, data["error_type"].to_s]
|
|
128
140
|
elsif data["error"].is_a?(Hash)
|
|
@@ -134,6 +146,7 @@ module ActiveHarness
|
|
|
134
146
|
when "invalid_request" then raise Errors::InvalidRequestError.new(msg, error_code: type)
|
|
135
147
|
when "unauthorized", "authentication_error" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
|
|
136
148
|
when "rate_limit", "rate_limit_error" then raise Errors::RateLimitError.new(msg, error_code: type)
|
|
149
|
+
when "server_error" then raise Errors::ServerError.new(msg, error_code: type)
|
|
137
150
|
when "customer_verification_required" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
|
|
138
151
|
else raise Errors::ProviderError.new(msg, error_code: type)
|
|
139
152
|
end
|
|
@@ -37,6 +37,7 @@ module ActiveHarness
|
|
|
37
37
|
vertexai: -> { Providers::VertexAI.new },
|
|
38
38
|
custom: -> { Providers::Custom.new },
|
|
39
39
|
vercel: -> { Providers::Vercel.new },
|
|
40
|
+
cohere: -> { Providers::Cohere.new },
|
|
40
41
|
typesafe: -> { Providers::TypeSafe.new },
|
|
41
42
|
ollama_cloud: -> { Providers::OllamaCloud.new },
|
|
42
43
|
hetzner: -> { Providers::Hetzner.new }
|
data/lib/active_harness.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative "active_harness/providers/vertexai"
|
|
|
23
23
|
require_relative "active_harness/providers/custom"
|
|
24
24
|
require_relative "active_harness/providers/vercel"
|
|
25
25
|
require_relative "active_harness/providers/typesafe"
|
|
26
|
+
require_relative "active_harness/providers/cohere"
|
|
26
27
|
require_relative "active_harness/providers/ollama_cloud"
|
|
27
28
|
require_relative "active_harness/providers/hetzner"
|
|
28
29
|
require_relative "active_harness/providers/images/openai"
|
|
@@ -40,7 +41,7 @@ require_relative "active_harness/pipeline"
|
|
|
40
41
|
require_relative "active_harness/railtie" if defined?(Rails::Railtie)
|
|
41
42
|
|
|
42
43
|
module ActiveHarness
|
|
43
|
-
VERSION = "0.3.
|
|
44
|
+
VERSION = "0.3.8"
|
|
44
45
|
|
|
45
46
|
class << self
|
|
46
47
|
# Configure ActiveHarness.
|
|
@@ -81,6 +81,12 @@ ActiveHarness.configure do |config|
|
|
|
81
81
|
# config.azure_ai_auth_token = ENV["AZURE_AI_AUTH_TOKEN"] # OAuth bearer (alternative)
|
|
82
82
|
# config.azure_api_version = "2024-05-01-preview"
|
|
83
83
|
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Cohere (native v2 Chat API)
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# config.cohere_api_key = ENV["COHERE_API_KEY"]
|
|
88
|
+
# config.cohere_api_url = "https://api.cohere.com/v2/chat"
|
|
89
|
+
|
|
84
90
|
# ---------------------------------------------------------------------------
|
|
85
91
|
# TypeSafe — Jev (System One) evaluation model, direct API
|
|
86
92
|
# ---------------------------------------------------------------------------
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_harness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- the-teacher
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- lib/active_harness/providers/azure.rb
|
|
71
71
|
- lib/active_harness/providers/base.rb
|
|
72
72
|
- lib/active_harness/providers/bedrock.rb
|
|
73
|
+
- lib/active_harness/providers/cohere.rb
|
|
73
74
|
- lib/active_harness/providers/custom.rb
|
|
74
75
|
- lib/active_harness/providers/deepseek.rb
|
|
75
76
|
- lib/active_harness/providers/gemini.rb
|