active_harness 0.3.5 → 0.3.7
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 +54 -0
- data/lib/active_harness/http/client.rb +11 -2
- data/lib/active_harness/providers/audio/deepgram.rb +81 -0
- data/lib/active_harness/providers/audio/elevenlabs.rb +103 -0
- data/lib/active_harness/providers/cohere.rb +101 -0
- data/lib/active_harness/providers/hetzner.rb +69 -0
- data/lib/active_harness/providers/ollama_cloud.rb +63 -0
- data/lib/active_harness/providers/typesafe.rb +35 -0
- data/lib/active_harness/providers/vercel.rb +43 -7
- data/lib/active_harness/request/providers.rb +8 -2
- data/lib/active_harness.rb +7 -1
- data/lib/generators/active_harness/install/templates/initializers/active_harness.rb +30 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54990f7712275af0727b75a7518f30f2ecf5ea798fb14548b5872997a6b272bc
|
|
4
|
+
data.tar.gz: d81370f86507d5c1d42bc12aac01209afb76bed63d2bb5545bb686ef85cb49ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc8f9d543c65fde9818611d68073b2a7de6285f37c815d81e6730c393103add7aed45ba9c9eaf7e9461ddbcf5062b3bd2e4978b3b690cbb190b0cf8eeff19209
|
|
7
|
+
data.tar.gz: 73f6e6c7a606e4f36bebf8cdd330534d4a37fd028f5f53fd2133c8a365caa136e62e5044baa54518b9ff8883022a6c4e4af4a01f84ea0d4bb4798c0d9b584d01
|
|
@@ -105,6 +105,42 @@ 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
|
+
|
|
114
|
+
# -------------------------------------------------------------------------
|
|
115
|
+
# TypeSafe — direct Jev (System One) API; also any Jev-compatible server
|
|
116
|
+
# -------------------------------------------------------------------------
|
|
117
|
+
attr_accessor :typesafe_api_key
|
|
118
|
+
attr_accessor :typesafe_api_url
|
|
119
|
+
|
|
120
|
+
# -------------------------------------------------------------------------
|
|
121
|
+
# Ollama Cloud (hosted — key required)
|
|
122
|
+
# -------------------------------------------------------------------------
|
|
123
|
+
attr_accessor :ollama_cloud_api_key
|
|
124
|
+
attr_accessor :ollama_cloud_api_url
|
|
125
|
+
|
|
126
|
+
# -------------------------------------------------------------------------
|
|
127
|
+
# Hetzner Inference
|
|
128
|
+
# -------------------------------------------------------------------------
|
|
129
|
+
attr_accessor :hetzner_api_key
|
|
130
|
+
attr_accessor :hetzner_api_url
|
|
131
|
+
|
|
132
|
+
# -------------------------------------------------------------------------
|
|
133
|
+
# Deepgram (audio transcription only)
|
|
134
|
+
# -------------------------------------------------------------------------
|
|
135
|
+
attr_accessor :deepgram_api_key
|
|
136
|
+
attr_accessor :deepgram_api_url
|
|
137
|
+
|
|
138
|
+
# -------------------------------------------------------------------------
|
|
139
|
+
# ElevenLabs (audio transcription only)
|
|
140
|
+
# -------------------------------------------------------------------------
|
|
141
|
+
attr_accessor :elevenlabs_api_key
|
|
142
|
+
attr_accessor :elevenlabs_api_url
|
|
143
|
+
|
|
108
144
|
# -------------------------------------------------------------------------
|
|
109
145
|
# Custom providers
|
|
110
146
|
#
|
|
@@ -179,6 +215,24 @@ module ActiveHarness
|
|
|
179
215
|
|
|
180
216
|
@vercel_api_key = ENV["VERCEL_API_KEY"]
|
|
181
217
|
@vercel_api_url = "https://ai-gateway.vercel.sh/typesafe/v1/systemone"
|
|
218
|
+
|
|
219
|
+
@cohere_api_key = ENV["COHERE_API_KEY"]
|
|
220
|
+
@cohere_api_url = "https://api.cohere.com/v2/chat"
|
|
221
|
+
|
|
222
|
+
@typesafe_api_key = ENV["TYPESAFE_API_KEY"]
|
|
223
|
+
@typesafe_api_url = "https://api.typesafe.ai/v1/systemone"
|
|
224
|
+
|
|
225
|
+
@ollama_cloud_api_key = ENV["OLLAMA_CLOUD_API_KEY"]
|
|
226
|
+
@ollama_cloud_api_url = "https://ollama.com/v1/chat/completions"
|
|
227
|
+
|
|
228
|
+
@hetzner_api_key = ENV["HETZNER_API_KEY"]
|
|
229
|
+
@hetzner_api_url = "https://inference.hetzner.com/api/v1/chat/completions"
|
|
230
|
+
|
|
231
|
+
@deepgram_api_key = ENV["DEEPGRAM_API_KEY"]
|
|
232
|
+
@deepgram_api_url = "https://api.deepgram.com/v1/listen"
|
|
233
|
+
|
|
234
|
+
@elevenlabs_api_key = ENV["ELEVENLABS_API_KEY"]
|
|
235
|
+
@elevenlabs_api_url = "https://api.elevenlabs.io/v1/speech-to-text"
|
|
182
236
|
end
|
|
183
237
|
end
|
|
184
238
|
end
|
|
@@ -8,7 +8,9 @@ module ActiveHarness
|
|
|
8
8
|
# @param headers [Hash{String => String}]
|
|
9
9
|
# @param body [String] JSON-serialized body
|
|
10
10
|
# @param timeout [Integer] seconds (open + read)
|
|
11
|
-
# @return [String] raw response body
|
|
11
|
+
# @return [String] raw response body; also responds to #http_status
|
|
12
|
+
# (Integer) so providers can classify errors by
|
|
13
|
+
# status without changing the return type
|
|
12
14
|
def post(url, headers:, body:, timeout: 30)
|
|
13
15
|
http = Net::HTTP.new(url.host, url.port)
|
|
14
16
|
http.use_ssl = true
|
|
@@ -19,7 +21,14 @@ module ActiveHarness
|
|
|
19
21
|
headers.each { |k, v| req[k] = v }
|
|
20
22
|
req.body = body
|
|
21
23
|
|
|
22
|
-
http.request(req)
|
|
24
|
+
res = http.request(req)
|
|
25
|
+
body = res.body
|
|
26
|
+
return body if body.nil?
|
|
27
|
+
|
|
28
|
+
body = body.dup
|
|
29
|
+
status = res.code.to_i
|
|
30
|
+
body.define_singleton_method(:http_status) { status }
|
|
31
|
+
body
|
|
23
32
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
24
33
|
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
25
34
|
rescue => e
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
module Audio
|
|
6
|
+
class Deepgram < Base
|
|
7
|
+
CONTENT_TYPES = {
|
|
8
|
+
"mp3" => "audio/mpeg",
|
|
9
|
+
"mp4" => "audio/mp4",
|
|
10
|
+
"m4a" => "audio/mp4",
|
|
11
|
+
"wav" => "audio/wav",
|
|
12
|
+
"flac" => "audio/flac",
|
|
13
|
+
"ogg" => "audio/ogg",
|
|
14
|
+
"opus" => "audio/ogg",
|
|
15
|
+
"webm" => "audio/webm",
|
|
16
|
+
"aac" => "audio/aac"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# @param model [String] e.g. "nova-3", "nova-2"
|
|
20
|
+
# @param audio_data [String] raw binary audio bytes
|
|
21
|
+
# @param audio_format [String] one of CONTENT_TYPES.keys
|
|
22
|
+
# @param language [String] BCP-47 code, e.g. "en" (optional)
|
|
23
|
+
#
|
|
24
|
+
# Synchronous pre-recorded transcription: the audio bytes are the raw
|
|
25
|
+
# request body (no multipart, no base64), options go in the query string.
|
|
26
|
+
# Deepgram bills by audio duration and reports no token usage, so
|
|
27
|
+
# `usage` is always nil.
|
|
28
|
+
def call(model:, audio_data:, audio_format:, language: nil, **_)
|
|
29
|
+
content_type = CONTENT_TYPES[audio_format]
|
|
30
|
+
unless content_type
|
|
31
|
+
raise Errors::InvalidRequestError,
|
|
32
|
+
"deepgram transcription does not support .#{audio_format} — use one of: #{CONTENT_TYPES.keys.join(', ')}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
query = { "model" => model, "smart_format" => "true" }
|
|
36
|
+
query["language"] = language if language
|
|
37
|
+
|
|
38
|
+
url = URI(config.deepgram_api_url)
|
|
39
|
+
url.query = URI.encode_www_form(query)
|
|
40
|
+
|
|
41
|
+
headers = { "Content-Type" => content_type, "Authorization" => "Token #{api_key}" }
|
|
42
|
+
|
|
43
|
+
raw = HTTP.post(url, headers: headers, body: audio_data, timeout: 90)
|
|
44
|
+
data = parse!(raw)
|
|
45
|
+
handle_error!(data)
|
|
46
|
+
|
|
47
|
+
text = data.dig("results", "channels", 0, "alternatives", 0, "transcript")
|
|
48
|
+
raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if text.nil?
|
|
49
|
+
|
|
50
|
+
{ content: text, provider: :deepgram, model: model, usage: nil }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def api_key
|
|
56
|
+
key = config.deepgram_api_key.to_s
|
|
57
|
+
raise Errors::InvalidApiKeyError, "deepgram_api_key is not configured" if key.empty?
|
|
58
|
+
key
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Deepgram errors look like { "err_code": "INVALID_AUTH", "err_msg": "..." }.
|
|
62
|
+
def handle_error!(data)
|
|
63
|
+
return unless data["err_code"] || data["err_msg"]
|
|
64
|
+
|
|
65
|
+
msg = data["err_msg"].to_s
|
|
66
|
+
code = data["err_code"].to_s
|
|
67
|
+
meta = data["request_id"] ? { request_id: data["request_id"] } : nil
|
|
68
|
+
|
|
69
|
+
case code
|
|
70
|
+
when "INVALID_AUTH", "INSUFFICIENT_PERMISSIONS"
|
|
71
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: meta)
|
|
72
|
+
when "TOO_MANY_REQUESTS", "RATE_LIMIT"
|
|
73
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: meta)
|
|
74
|
+
else
|
|
75
|
+
raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: meta)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module ActiveHarness
|
|
5
|
+
module Providers
|
|
6
|
+
module Audio
|
|
7
|
+
class ElevenLabs < Base
|
|
8
|
+
CONTENT_TYPES = {
|
|
9
|
+
"mp3" => "audio/mpeg",
|
|
10
|
+
"mp4" => "audio/mp4",
|
|
11
|
+
"m4a" => "audio/mp4",
|
|
12
|
+
"wav" => "audio/wav",
|
|
13
|
+
"flac" => "audio/flac",
|
|
14
|
+
"ogg" => "audio/ogg",
|
|
15
|
+
"webm" => "audio/webm",
|
|
16
|
+
"aac" => "audio/aac"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# @param model [String] e.g. "scribe_v1", "scribe_v2"
|
|
20
|
+
# @param audio_data [String] raw binary audio bytes
|
|
21
|
+
# @param audio_format [String] one of CONTENT_TYPES.keys
|
|
22
|
+
# @param language [String] ISO-639-1/3 code (optional — auto-detected if omitted)
|
|
23
|
+
#
|
|
24
|
+
# Synchronous multipart/form-data request. ElevenLabs reports no token
|
|
25
|
+
# usage for speech-to-text, so `usage` is always nil.
|
|
26
|
+
def call(model:, audio_data:, audio_format:, language: nil, **_)
|
|
27
|
+
content_type = CONTENT_TYPES[audio_format]
|
|
28
|
+
unless content_type
|
|
29
|
+
raise Errors::InvalidRequestError,
|
|
30
|
+
"elevenlabs transcription does not support .#{audio_format} — use one of: #{CONTENT_TYPES.keys.join(', ')}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
boundary = SecureRandom.hex(16)
|
|
34
|
+
fields = { "model_id" => model }
|
|
35
|
+
fields["language_code"] = language if language
|
|
36
|
+
|
|
37
|
+
headers = {
|
|
38
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}",
|
|
39
|
+
"xi-api-key" => api_key
|
|
40
|
+
}
|
|
41
|
+
body = build_multipart_body(boundary, fields, audio_data, "audio.#{audio_format}", content_type)
|
|
42
|
+
|
|
43
|
+
raw = HTTP.post(URI(config.elevenlabs_api_url), headers: headers, body: body, timeout: 90)
|
|
44
|
+
data = parse!(raw)
|
|
45
|
+
handle_error!(data)
|
|
46
|
+
|
|
47
|
+
text = data["text"]
|
|
48
|
+
raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if text.nil?
|
|
49
|
+
|
|
50
|
+
{ content: text, provider: :elevenlabs, model: model, usage: nil }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_multipart_body(boundary, fields, file_data, filename, content_type)
|
|
56
|
+
body = +""
|
|
57
|
+
fields.each do |name, value|
|
|
58
|
+
body << "--#{boundary}\r\n"
|
|
59
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
|
|
60
|
+
body << "#{value}\r\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
body << "--#{boundary}\r\n"
|
|
64
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
|
|
65
|
+
body << "Content-Type: #{content_type}\r\n\r\n"
|
|
66
|
+
body << file_data
|
|
67
|
+
body << "\r\n--#{boundary}--\r\n"
|
|
68
|
+
body
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def api_key
|
|
72
|
+
key = config.elevenlabs_api_key.to_s
|
|
73
|
+
raise Errors::InvalidApiKeyError, "elevenlabs_api_key is not configured" if key.empty?
|
|
74
|
+
key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Errors are { "detail": { "status": "...", "message": "..." } }, or for
|
|
78
|
+
# request-validation failures { "detail": [ { "msg": "...", ... } ] }.
|
|
79
|
+
def handle_error!(data)
|
|
80
|
+
detail = data["detail"]
|
|
81
|
+
return unless detail
|
|
82
|
+
|
|
83
|
+
if detail.is_a?(Hash)
|
|
84
|
+
msg = detail["message"].to_s
|
|
85
|
+
code = detail["status"].to_s
|
|
86
|
+
else
|
|
87
|
+
msg = Array(detail).map { |d| d.is_a?(Hash) ? d["msg"] : d }.join("; ")
|
|
88
|
+
code = "validation_error"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
case code
|
|
92
|
+
when "invalid_api_key", "unauthorized", "needs_authorization"
|
|
93
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code)
|
|
94
|
+
when "too_many_concurrent_requests", "rate_limit_exceeded", "system_busy"
|
|
95
|
+
raise Errors::RateLimitError.new(msg, error_code: code)
|
|
96
|
+
else
|
|
97
|
+
raise Errors::InvalidRequestError.new(msg, error_code: code)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Hetzner Inference — OpenAI-compatible Chat Completions API.
|
|
6
|
+
# https://inference.hetzner.com
|
|
7
|
+
#
|
|
8
|
+
# Hetzner's servers cannot fetch image URLs, so image inputs must be sent
|
|
9
|
+
# inline (data URIs); plain-text chat is unaffected.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# model do
|
|
13
|
+
# use provider: :hetzner, model: "meta-llama/Llama-3.3-70B-Instruct"
|
|
14
|
+
# end
|
|
15
|
+
class Hetzner < Base
|
|
16
|
+
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
17
|
+
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
18
|
+
body = { model: model, messages: messages, temperature: temperature }
|
|
19
|
+
|
|
20
|
+
return call_streaming(url: config.hetzner_api_url, headers: headers, body: body, stream: stream, provider: :hetzner, model: model) if stream
|
|
21
|
+
|
|
22
|
+
raw = post_json(URI(config.hetzner_api_url), headers: headers, body: body)
|
|
23
|
+
data = parse!(raw)
|
|
24
|
+
handle_error!(data)
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
28
|
+
provider: :hetzner,
|
|
29
|
+
model: data["model"] || model,
|
|
30
|
+
usage: extract_usage_openai(data)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def api_key
|
|
37
|
+
key = config.hetzner_api_key.to_s
|
|
38
|
+
raise Errors::InvalidApiKeyError, "hetzner_api_key is not configured" if key.empty?
|
|
39
|
+
key
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_error!(data)
|
|
43
|
+
return unless data["error"]
|
|
44
|
+
|
|
45
|
+
err = data["error"]
|
|
46
|
+
msg = err.is_a?(Hash) ? err["message"].to_s : err.to_s
|
|
47
|
+
code = err.is_a?(Hash) ? err["code"].to_s : ""
|
|
48
|
+
type = err.is_a?(Hash) ? err["type"].to_s : ""
|
|
49
|
+
metadata = err.is_a?(Hash) ? err.reject { |k, _| %w[message code type].include?(k) } : {}
|
|
50
|
+
metadata = nil if metadata.empty?
|
|
51
|
+
|
|
52
|
+
case code
|
|
53
|
+
when "401", "invalid_api_key", "unauthorized"
|
|
54
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
55
|
+
when "429", "rate_limit_exceeded"
|
|
56
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
57
|
+
when "500", "502", "503", "504"
|
|
58
|
+
raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
|
|
59
|
+
else
|
|
60
|
+
if type == "server_error"
|
|
61
|
+
raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
|
|
62
|
+
else
|
|
63
|
+
raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# Ollama Cloud — hosted Ollama models, OpenAI-compatible API (key required).
|
|
6
|
+
# https://docs.ollama.com/cloud
|
|
7
|
+
#
|
|
8
|
+
# For a local Ollama server use provider: :ollama instead.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# model do
|
|
12
|
+
# use provider: :ollama_cloud, model: "gpt-oss:120b"
|
|
13
|
+
# end
|
|
14
|
+
class OllamaCloud < Base
|
|
15
|
+
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
16
|
+
headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{api_key}" }
|
|
17
|
+
body = { model: model, messages: messages, temperature: temperature }
|
|
18
|
+
|
|
19
|
+
return call_streaming(url: config.ollama_cloud_api_url, headers: headers, body: body, stream: stream, provider: :ollama_cloud, model: model) if stream
|
|
20
|
+
|
|
21
|
+
raw = post_json(URI(config.ollama_cloud_api_url), headers: headers, body: body)
|
|
22
|
+
data = parse!(raw)
|
|
23
|
+
handle_error!(data)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
content: data.dig("choices", 0, "message", "content").to_s.strip,
|
|
27
|
+
provider: :ollama_cloud,
|
|
28
|
+
model: data["model"] || model,
|
|
29
|
+
usage: extract_usage_openai(data)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def api_key
|
|
36
|
+
key = config.ollama_cloud_api_key.to_s
|
|
37
|
+
raise Errors::InvalidApiKeyError, "ollama_cloud_api_key is not configured" if key.empty?
|
|
38
|
+
key
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def handle_error!(data)
|
|
42
|
+
return unless data["error"]
|
|
43
|
+
|
|
44
|
+
err = data["error"]
|
|
45
|
+
msg = err.is_a?(Hash) ? err["message"].to_s : err.to_s
|
|
46
|
+
code = err.is_a?(Hash) ? err["code"].to_s : ""
|
|
47
|
+
metadata = err.is_a?(Hash) ? err.reject { |k, _| %w[message code].include?(k) } : {}
|
|
48
|
+
metadata = nil if metadata.empty?
|
|
49
|
+
|
|
50
|
+
case code
|
|
51
|
+
when "401", "invalid_api_key", "unauthorized"
|
|
52
|
+
raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
|
|
53
|
+
when "429", "rate_limit_exceeded"
|
|
54
|
+
raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
|
|
55
|
+
when "500", "502", "503", "504"
|
|
56
|
+
raise Errors::ProviderUnavailableError.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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Providers
|
|
5
|
+
# TypeSafe — direct access to Jev, TypeSafe's "System One" evaluation model
|
|
6
|
+
# (https://api.typesafe.ai/v1/systemone). Same request/response contract as
|
|
7
|
+
# provider: :vercel (which reaches the same model through Vercel AI Gateway),
|
|
8
|
+
# so `questions:` and everything else in Providers::Vercel apply unchanged.
|
|
9
|
+
#
|
|
10
|
+
# Also works with any Jev-compatible server: point `typesafe_api_url` at it.
|
|
11
|
+
#
|
|
12
|
+
# model do
|
|
13
|
+
# use provider: :typesafe, model: "jev-latest", questions: {
|
|
14
|
+
# urgent: { type: "noul", instructions: "Is this urgent?" }
|
|
15
|
+
# }
|
|
16
|
+
# end
|
|
17
|
+
class TypeSafe < Vercel
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def provider_name
|
|
21
|
+
:typesafe
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def endpoint_url
|
|
25
|
+
config.typesafe_api_url
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def api_key
|
|
29
|
+
key = config.typesafe_api_key.to_s
|
|
30
|
+
raise Errors::InvalidApiKeyError, "typesafe_api_key is not configured" if key.empty?
|
|
31
|
+
key
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -44,7 +44,7 @@ module ActiveHarness
|
|
|
44
44
|
DEFAULT_INSTRUCTIONS = "Evaluate the given state.".freeze
|
|
45
45
|
|
|
46
46
|
def call(model:, messages:, temperature: nil, stream: nil, questions: nil)
|
|
47
|
-
raise Errors::InvalidRequestError, "provider:
|
|
47
|
+
raise Errors::InvalidRequestError, "provider: :#{provider_name} (Jev) does not support token streaming" if stream
|
|
48
48
|
|
|
49
49
|
state = messages.reverse.find { |m| m[:role] == "user" }&.fetch(:content, nil).to_s
|
|
50
50
|
|
|
@@ -58,13 +58,13 @@ module ActiveHarness
|
|
|
58
58
|
questions: questions || default_questions(messages)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
raw = post_json(URI(
|
|
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"]),
|
|
67
|
-
provider:
|
|
67
|
+
provider: provider_name,
|
|
68
68
|
model: data["model"] || model,
|
|
69
69
|
usage: extract_usage(data)
|
|
70
70
|
}
|
|
@@ -78,6 +78,15 @@ module ActiveHarness
|
|
|
78
78
|
{ answer: { type: "noul", instructions: instructions } }
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Subclasses (e.g. TypeSafe) reuse everything above and only swap these.
|
|
82
|
+
def provider_name
|
|
83
|
+
:vercel
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def endpoint_url
|
|
87
|
+
config.vercel_api_url
|
|
88
|
+
end
|
|
89
|
+
|
|
81
90
|
def api_key
|
|
82
91
|
key = config.vercel_api_key.to_s
|
|
83
92
|
raise Errors::InvalidApiKeyError, "vercel_api_key is not configured" if key.empty?
|
|
@@ -95,12 +104,38 @@ module ActiveHarness
|
|
|
95
104
|
{ input_tokens: input, output_tokens: output, total_tokens: input + output }
|
|
96
105
|
end
|
|
97
106
|
|
|
98
|
-
#
|
|
107
|
+
# TypeSafe's own API reports { "detail": String | { "message": ... } |
|
|
108
|
+
# [ { "loc": [...], "msg": "..." } ] } (FastAPI-style).
|
|
109
|
+
def detail_message(detail)
|
|
110
|
+
case detail
|
|
111
|
+
when Hash then detail["message"].to_s
|
|
112
|
+
when Array then detail.map { |e| e.is_a?(Hash) ? [Array(e["loc"]).join("."), e["msg"]].reject { |x| x.to_s.empty? }.join(": ") : e.to_s }.join("; ")
|
|
113
|
+
else detail.to_s
|
|
114
|
+
end
|
|
115
|
+
end
|
|
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
|
+
|
|
129
|
+
# Error shapes seen in practice:
|
|
130
|
+
# - TypeSafe direct API: { "detail": ... } (see detail_message)
|
|
99
131
|
# - TypeSafe request-validation errors: { "message": "...", "error_type": "..." }
|
|
100
132
|
# - AI Gateway account/billing errors (OpenAI-style, nested): { "error": { "message": "...", "type": "..." } }
|
|
101
|
-
def handle_error!(data)
|
|
133
|
+
def handle_error!(data, status: nil)
|
|
102
134
|
msg, type =
|
|
103
|
-
if data
|
|
135
|
+
if data.key?("detail")
|
|
136
|
+
msg = detail_message(data["detail"])
|
|
137
|
+
[msg, detail_error_type(msg, status)]
|
|
138
|
+
elsif data["message"] && data["error_type"]
|
|
104
139
|
[data["message"].to_s, data["error_type"].to_s]
|
|
105
140
|
elsif data["error"].is_a?(Hash)
|
|
106
141
|
[data["error"]["message"].to_s, data["error"]["type"].to_s]
|
|
@@ -111,6 +146,7 @@ module ActiveHarness
|
|
|
111
146
|
when "invalid_request" then raise Errors::InvalidRequestError.new(msg, error_code: type)
|
|
112
147
|
when "unauthorized", "authentication_error" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
|
|
113
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)
|
|
114
150
|
when "customer_verification_required" then raise Errors::InvalidApiKeyError.new(msg, error_code: type)
|
|
115
151
|
else raise Errors::ProviderError.new(msg, error_code: type)
|
|
116
152
|
end
|
|
@@ -36,7 +36,11 @@ module ActiveHarness
|
|
|
36
36
|
bedrock: -> { Providers::Bedrock.new },
|
|
37
37
|
vertexai: -> { Providers::VertexAI.new },
|
|
38
38
|
custom: -> { Providers::Custom.new },
|
|
39
|
-
vercel: -> { Providers::Vercel.new }
|
|
39
|
+
vercel: -> { Providers::Vercel.new },
|
|
40
|
+
cohere: -> { Providers::Cohere.new },
|
|
41
|
+
typesafe: -> { Providers::TypeSafe.new },
|
|
42
|
+
ollama_cloud: -> { Providers::OllamaCloud.new },
|
|
43
|
+
hetzner: -> { Providers::Hetzner.new }
|
|
40
44
|
}.freeze
|
|
41
45
|
|
|
42
46
|
IMAGE_PROVIDERS = {
|
|
@@ -46,7 +50,9 @@ module ActiveHarness
|
|
|
46
50
|
|
|
47
51
|
TRANSCRIPTION_PROVIDERS = {
|
|
48
52
|
openai: -> { Providers::Audio::OpenAI.new },
|
|
49
|
-
openrouter: -> { Providers::Audio::OpenRouter.new }
|
|
53
|
+
openrouter: -> { Providers::Audio::OpenRouter.new },
|
|
54
|
+
deepgram: -> { Providers::Audio::Deepgram.new },
|
|
55
|
+
elevenlabs: -> { Providers::Audio::ElevenLabs.new }
|
|
50
56
|
}.freeze
|
|
51
57
|
|
|
52
58
|
private
|
data/lib/active_harness.rb
CHANGED
|
@@ -22,10 +22,16 @@ require_relative "active_harness/providers/bedrock"
|
|
|
22
22
|
require_relative "active_harness/providers/vertexai"
|
|
23
23
|
require_relative "active_harness/providers/custom"
|
|
24
24
|
require_relative "active_harness/providers/vercel"
|
|
25
|
+
require_relative "active_harness/providers/typesafe"
|
|
26
|
+
require_relative "active_harness/providers/cohere"
|
|
27
|
+
require_relative "active_harness/providers/ollama_cloud"
|
|
28
|
+
require_relative "active_harness/providers/hetzner"
|
|
25
29
|
require_relative "active_harness/providers/images/openai"
|
|
26
30
|
require_relative "active_harness/providers/images/openrouter"
|
|
27
31
|
require_relative "active_harness/providers/audio/openai"
|
|
28
32
|
require_relative "active_harness/providers/audio/openrouter"
|
|
33
|
+
require_relative "active_harness/providers/audio/deepgram"
|
|
34
|
+
require_relative "active_harness/providers/audio/elevenlabs"
|
|
29
35
|
require "active_harness_pricing"
|
|
30
36
|
require_relative "active_harness/memory"
|
|
31
37
|
require_relative "active_harness/request"
|
|
@@ -35,7 +41,7 @@ require_relative "active_harness/pipeline"
|
|
|
35
41
|
require_relative "active_harness/railtie" if defined?(Rails::Railtie)
|
|
36
42
|
|
|
37
43
|
module ActiveHarness
|
|
38
|
-
VERSION = "0.3.
|
|
44
|
+
VERSION = "0.3.7"
|
|
39
45
|
|
|
40
46
|
class << self
|
|
41
47
|
# Configure ActiveHarness.
|
|
@@ -81,6 +81,36 @@ 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
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# TypeSafe — Jev (System One) evaluation model, direct API
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# config.typesafe_api_key = ENV["TYPESAFE_API_KEY"]
|
|
94
|
+
# config.typesafe_api_url = "https://api.typesafe.ai/v1/systemone"
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Ollama Cloud (hosted — API key required)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# config.ollama_cloud_api_key = ENV["OLLAMA_CLOUD_API_KEY"]
|
|
100
|
+
# config.ollama_cloud_api_url = "https://ollama.com/v1/chat/completions"
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Hetzner Inference
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# config.hetzner_api_key = ENV["HETZNER_API_KEY"]
|
|
106
|
+
# config.hetzner_api_url = "https://inference.hetzner.com/api/v1/chat/completions"
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Deepgram / ElevenLabs (audio transcription only)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# config.deepgram_api_key = ENV["DEEPGRAM_API_KEY"]
|
|
112
|
+
# config.elevenlabs_api_key = ENV["ELEVENLABS_API_KEY"]
|
|
113
|
+
|
|
84
114
|
# ---------------------------------------------------------------------------
|
|
85
115
|
# Custom providers — any OpenAI-compatible endpoint
|
|
86
116
|
# Register as many as you need under arbitrary names.
|
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.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- the-teacher
|
|
@@ -63,23 +63,29 @@ files:
|
|
|
63
63
|
- lib/active_harness/pipeline/step.rb
|
|
64
64
|
- lib/active_harness/providers/PROVIDER_CONTRACT.md
|
|
65
65
|
- lib/active_harness/providers/anthropic.rb
|
|
66
|
+
- lib/active_harness/providers/audio/deepgram.rb
|
|
67
|
+
- lib/active_harness/providers/audio/elevenlabs.rb
|
|
66
68
|
- lib/active_harness/providers/audio/openai.rb
|
|
67
69
|
- lib/active_harness/providers/audio/openrouter.rb
|
|
68
70
|
- lib/active_harness/providers/azure.rb
|
|
69
71
|
- lib/active_harness/providers/base.rb
|
|
70
72
|
- lib/active_harness/providers/bedrock.rb
|
|
73
|
+
- lib/active_harness/providers/cohere.rb
|
|
71
74
|
- lib/active_harness/providers/custom.rb
|
|
72
75
|
- lib/active_harness/providers/deepseek.rb
|
|
73
76
|
- lib/active_harness/providers/gemini.rb
|
|
74
77
|
- lib/active_harness/providers/gpustack.rb
|
|
75
78
|
- lib/active_harness/providers/groq.rb
|
|
79
|
+
- lib/active_harness/providers/hetzner.rb
|
|
76
80
|
- lib/active_harness/providers/images/openai.rb
|
|
77
81
|
- lib/active_harness/providers/images/openrouter.rb
|
|
78
82
|
- lib/active_harness/providers/mistral.rb
|
|
79
83
|
- lib/active_harness/providers/ollama.rb
|
|
84
|
+
- lib/active_harness/providers/ollama_cloud.rb
|
|
80
85
|
- lib/active_harness/providers/openai.rb
|
|
81
86
|
- lib/active_harness/providers/openrouter.rb
|
|
82
87
|
- lib/active_harness/providers/perplexity.rb
|
|
88
|
+
- lib/active_harness/providers/typesafe.rb
|
|
83
89
|
- lib/active_harness/providers/vercel.rb
|
|
84
90
|
- lib/active_harness/providers/vertexai.rb
|
|
85
91
|
- lib/active_harness/providers/xai.rb
|