active_harness 0.3.4 → 0.3.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eaf485a5e22b0e8896fde6333964ccfb2f9739a6f366707587d4c3a9a1cbcec7
4
- data.tar.gz: 9fe117e4d320affd189bd8ef8f601d0481c858f8087d509529bd79d04d710a1d
3
+ metadata.gz: 347d7944af01b44bda7568ac47fc335fd58a48320f80ae71c4a52b83458a710f
4
+ data.tar.gz: 13343b28d031fd4dd86b4114a7d4c3f7b7223db6402b3c1f2bf9e135073e3bc3
5
5
  SHA512:
6
- metadata.gz: e115a0659d759419465014e3a5dbdbecabb6e26b9fb6aa0f4909debd117ea0f1aca33f302170dc4d4a9ea4d3769edbc3ba64b230bf824ad2cc787ef1e384336c
7
- data.tar.gz: f04b58a682ae1af32e56a7afb96517a376136e9990366529406706f1c6af66ae6f446185da5d775df50ad93f95ef54b95deb86d0d98981169e20acd165570094
6
+ metadata.gz: 3eb2200ec4c40e7ecf626d9cff3c5f56917b07dbf5756fa54d986d327ce740b18b95b5806f4e9924674a010196c76bbb11f9c66fbe9f54256ac0cfce4c901a7c
7
+ data.tar.gz: 8c1c186d855e6ef3ee0e8961665904da7175cf7018c0e4e352eb2b6a15bcd42f2d019963cb996d0bb8407f8eeb9eda98bac13b8def11fd2c43b382a8dce8d8f5
@@ -105,6 +105,36 @@ module ActiveHarness
105
105
  attr_accessor :vercel_api_key
106
106
  attr_accessor :vercel_api_url
107
107
 
108
+ # -------------------------------------------------------------------------
109
+ # TypeSafe — direct Jev (System One) API; also any Jev-compatible server
110
+ # -------------------------------------------------------------------------
111
+ attr_accessor :typesafe_api_key
112
+ attr_accessor :typesafe_api_url
113
+
114
+ # -------------------------------------------------------------------------
115
+ # Ollama Cloud (hosted — key required)
116
+ # -------------------------------------------------------------------------
117
+ attr_accessor :ollama_cloud_api_key
118
+ attr_accessor :ollama_cloud_api_url
119
+
120
+ # -------------------------------------------------------------------------
121
+ # Hetzner Inference
122
+ # -------------------------------------------------------------------------
123
+ attr_accessor :hetzner_api_key
124
+ attr_accessor :hetzner_api_url
125
+
126
+ # -------------------------------------------------------------------------
127
+ # Deepgram (audio transcription only)
128
+ # -------------------------------------------------------------------------
129
+ attr_accessor :deepgram_api_key
130
+ attr_accessor :deepgram_api_url
131
+
132
+ # -------------------------------------------------------------------------
133
+ # ElevenLabs (audio transcription only)
134
+ # -------------------------------------------------------------------------
135
+ attr_accessor :elevenlabs_api_key
136
+ attr_accessor :elevenlabs_api_url
137
+
108
138
  # -------------------------------------------------------------------------
109
139
  # Custom providers
110
140
  #
@@ -179,6 +209,21 @@ module ActiveHarness
179
209
 
180
210
  @vercel_api_key = ENV["VERCEL_API_KEY"]
181
211
  @vercel_api_url = "https://ai-gateway.vercel.sh/typesafe/v1/systemone"
212
+
213
+ @typesafe_api_key = ENV["TYPESAFE_API_KEY"]
214
+ @typesafe_api_url = "https://api.typesafe.ai/v1/systemone"
215
+
216
+ @ollama_cloud_api_key = ENV["OLLAMA_CLOUD_API_KEY"]
217
+ @ollama_cloud_api_url = "https://ollama.com/v1/chat/completions"
218
+
219
+ @hetzner_api_key = ENV["HETZNER_API_KEY"]
220
+ @hetzner_api_url = "https://inference.hetzner.com/api/v1/chat/completions"
221
+
222
+ @deepgram_api_key = ENV["DEEPGRAM_API_KEY"]
223
+ @deepgram_api_url = "https://api.deepgram.com/v1/listen"
224
+
225
+ @elevenlabs_api_key = ENV["ELEVENLABS_API_KEY"]
226
+ @elevenlabs_api_url = "https://api.elevenlabs.io/v1/speech-to-text"
182
227
  end
183
228
  end
184
229
  end
@@ -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
@@ -27,7 +27,18 @@ module ActiveHarness
27
27
  # Synchronous — this endpoint has no job/polling API. Unlike OpenRouter's
28
28
  # transcription endpoint, OpenAI's is multipart/form-data only (no
29
29
  # base64/JSON request mode).
30
- def call(model:, audio_data:, audio_format:, language: nil, **_)
30
+ # @param response_format [String] "json" (default, plain text) or,
31
+ # for gpt-4o-transcribe-diarize, "diarized_json" — returns per-segment
32
+ # speaker/text/start/end instead of just plain text.
33
+ # @param chunking_strategy [String, Hash] required by
34
+ # gpt-4o-transcribe-diarize for any audio over 30 seconds; "auto" is
35
+ # the simple/common value, or a voice-activity-detection config hash.
36
+ # @param known_speaker_names [Array<String>] up to 4 labels, paired
37
+ # positionally with known_speaker_references, for gpt-4o-transcribe-diarize.
38
+ # @param known_speaker_references [Array<String>] up to 4 short (2-10s)
39
+ # reference clips as base64 data URLs, e.g. "data:audio/wav;base64,...".
40
+ def call(model:, audio_data:, audio_format:, language: nil, response_format: nil,
41
+ chunking_strategy: nil, known_speaker_names: nil, known_speaker_references: nil, **_)
31
42
  content_type = CONTENT_TYPES[audio_format]
32
43
  unless content_type
33
44
  raise Errors::InvalidRequestError,
@@ -36,9 +47,16 @@ module ActiveHarness
36
47
 
37
48
  boundary = SecureRandom.hex(16)
38
49
  fields = { "model" => model }
39
- fields["language"] = language if language
50
+ fields["language"] = language if language
51
+ fields["response_format"] = response_format if response_format
52
+ fields["chunking_strategy"] = chunking_strategy if chunking_strategy
40
53
 
41
- body = build_multipart_body(boundary, fields, audio_data, "audio.#{audio_format}", content_type)
54
+ array_fields = {
55
+ "known_speaker_names[]" => known_speaker_names,
56
+ "known_speaker_references[]" => known_speaker_references
57
+ }
58
+
59
+ body = build_multipart_body(boundary, fields, audio_data, "audio.#{audio_format}", content_type, array_fields: array_fields)
42
60
  headers = {
43
61
  "Content-Type" => "multipart/form-data; boundary=#{boundary}",
44
62
  "Authorization" => "Bearer #{api_key}"
@@ -48,15 +66,24 @@ module ActiveHarness
48
66
  data = parse!(raw)
49
67
  handle_error!(data)
50
68
 
51
- text = data["text"]
52
- raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if text.nil?
69
+ # diarized_json returns a structured { segments: [...] } shape (each
70
+ # with speaker/text/start/end) — hand the whole thing back as JSON
71
+ # text so `format :json` on the Request subclass parses it into a
72
+ # Hash. Plain "json"/unset keeps the existing behavior: just the
73
+ # flat text.
74
+ content =
75
+ if response_format == "diarized_json"
76
+ data.to_json
77
+ else
78
+ data["text"].tap { |t| raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if t.nil? }
79
+ end
53
80
 
54
- { content: text, provider: :openai, model: model, usage: extract_transcription_usage(data) }
81
+ { content: content, provider: :openai, model: model, usage: extract_transcription_usage(data) }
55
82
  end
56
83
 
57
84
  private
58
85
 
59
- def build_multipart_body(boundary, fields, file_data, filename, content_type)
86
+ def build_multipart_body(boundary, fields, file_data, filename, content_type, array_fields: {})
60
87
  body = +""
61
88
  fields.each do |name, value|
62
89
  body << "--#{boundary}\r\n"
@@ -64,6 +91,14 @@ module ActiveHarness
64
91
  body << "#{value}\r\n"
65
92
  end
66
93
 
94
+ array_fields.each do |name, values|
95
+ Array(values).each do |value|
96
+ body << "--#{boundary}\r\n"
97
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
98
+ body << "#{value}\r\n"
99
+ end
100
+ end
101
+
67
102
  body << "--#{boundary}\r\n"
68
103
  body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
69
104
  body << "Content-Type: #{content_type}\r\n\r\n"
@@ -13,11 +13,22 @@ module ActiveHarness
13
13
  # @param audio_data [String] raw binary audio bytes
14
14
  # @param audio_format [String] "wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"
15
15
  # @param language [String] ISO-639-1 code, e.g. "en" (optional — auto-detected if omitted)
16
+ # @param response_format [String] "json" (default, plain text) or "verbose_json"
17
+ # — the latter returns per-segment start/end timestamps (and a "speaker"
18
+ # index per segment when the provider supports diarization).
19
+ # @param timestamp_granularities [Array<String>] e.g. ["segment", "word"] —
20
+ # only meaningful with response_format: "verbose_json".
21
+ # @param provider_options [Hash] passed through as-is to the request body's
22
+ # `provider.options` — this is how provider-specific features (e.g. Azure
23
+ # speaker diarization) are enabled: { azure: { diarization: { enabled: true } } }.
24
+ # Only specific provider/model combos support this; most will just ignore
25
+ # an option they don't recognize, but some may 400 on it.
16
26
  #
17
27
  # Synchronous call — OpenRouter's transcription endpoint has no job/polling
18
28
  # API. Upstream providers time out after ~60s per request, so long audio
19
29
  # should be split into shorter chunks by the caller before transcribing.
20
- def call(model:, audio_data:, audio_format:, language: nil, **_)
30
+ def call(model:, audio_data:, audio_format:, language: nil, response_format: nil,
31
+ timestamp_granularities: nil, provider_options: nil, **_)
21
32
  headers = {
22
33
  "Content-Type" => "application/json",
23
34
  "Authorization" => "Bearer #{api_key}"
@@ -29,16 +40,27 @@ module ActiveHarness
29
40
  model: model,
30
41
  input_audio: { data: Base64.strict_encode64(audio_data), format: audio_format }
31
42
  }
32
- body[:language] = language if language
43
+ body[:language] = language if language
44
+ body[:response_format] = response_format if response_format
45
+ body[:timestamp_granularities] = timestamp_granularities if timestamp_granularities
46
+ body[:provider] = { options: provider_options } if provider_options
33
47
 
34
48
  raw = post_json(URI(ENDPOINT), headers: headers, body: body, timeout: 90)
35
49
  data = parse!(raw)
36
50
  handle_error!(data)
37
51
 
38
- text = data["text"]
39
- raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if text.nil?
52
+ # verbose_json returns a richer structure (language/duration/segments with
53
+ # timestamps and, when supported, speaker) — hand the whole thing back as
54
+ # JSON text so `format :json` on the Request subclass parses it into a Hash.
55
+ # Plain "json"/unset keeps the existing behavior: just the flat text.
56
+ content =
57
+ if response_format == "verbose_json"
58
+ data.to_json
59
+ else
60
+ data["text"].tap { |t| raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if t.nil? }
61
+ end
40
62
 
41
- { content: text, provider: :openrouter, model: model, usage: extract_transcription_usage(data) }
63
+ { content: content, provider: :openrouter, model: model, usage: extract_transcription_usage(data) }
42
64
  end
43
65
 
44
66
  private
@@ -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: :vercel (Jev) does not support token streaming" if stream
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(config.vercel_api_url), headers: headers, body: body)
61
+ raw = post_json(URI(endpoint_url), headers: headers, body: body)
62
62
  data = parse!(raw)
63
63
  handle_error!(data)
64
64
 
65
65
  {
66
66
  content: JSON.pretty_generate(data["answers"]),
67
- provider: :vercel,
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,26 @@ module ActiveHarness
95
104
  { input_tokens: input, output_tokens: output, total_tokens: input + output }
96
105
  end
97
106
 
98
- # Two error shapes seen in practice:
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
+ # Error shapes seen in practice:
118
+ # - TypeSafe direct API: { "detail": ... } (see detail_message)
99
119
  # - TypeSafe request-validation errors: { "message": "...", "error_type": "..." }
100
120
  # - AI Gateway account/billing errors (OpenAI-style, nested): { "error": { "message": "...", "type": "..." } }
101
121
  def handle_error!(data)
102
122
  msg, type =
103
- if data["message"] && data["error_type"]
123
+ if data.key?("detail")
124
+ msg = detail_message(data["detail"])
125
+ [msg, msg =~ /api key|unauthori|authenticat|invalid token|credentials/i ? "unauthorized" : "invalid_request"]
126
+ elsif data["message"] && data["error_type"]
104
127
  [data["message"].to_s, data["error_type"].to_s]
105
128
  elsif data["error"].is_a?(Hash)
106
129
  [data["error"]["message"].to_s, data["error"]["type"].to_s]
@@ -134,17 +134,25 @@ module ActiveHarness
134
134
  @models = []
135
135
  end
136
136
 
137
- def use(provider:, model:, temperature: nil, name: nil, size: nil, quality: nil, retry_attempts: nil, retry_delay: nil, questions: nil)
137
+ def use(provider:, model:, temperature: nil, name: nil, size: nil, quality: nil, retry_attempts: nil, retry_delay: nil,
138
+ questions: nil, response_format: nil, timestamp_granularities: nil, provider_options: nil,
139
+ chunking_strategy: nil, known_speaker_names: nil, known_speaker_references: nil)
138
140
  @models << {
139
- provider: provider,
140
- model: model,
141
- temperature: temperature,
142
- name: name,
143
- size: size,
144
- quality: quality,
145
- retry_attempts: retry_attempts,
146
- retry_delay: retry_delay,
147
- questions: questions
141
+ provider: provider,
142
+ model: model,
143
+ temperature: temperature,
144
+ name: name,
145
+ size: size,
146
+ quality: quality,
147
+ retry_attempts: retry_attempts,
148
+ retry_delay: retry_delay,
149
+ questions: questions,
150
+ response_format: response_format,
151
+ timestamp_granularities: timestamp_granularities,
152
+ provider_options: provider_options,
153
+ chunking_strategy: chunking_strategy,
154
+ known_speaker_names: known_speaker_names,
155
+ known_speaker_references: known_speaker_references
148
156
  }.compact
149
157
  end
150
158
 
@@ -36,7 +36,10 @@ 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
+ typesafe: -> { Providers::TypeSafe.new },
41
+ ollama_cloud: -> { Providers::OllamaCloud.new },
42
+ hetzner: -> { Providers::Hetzner.new }
40
43
  }.freeze
41
44
 
42
45
  IMAGE_PROVIDERS = {
@@ -46,7 +49,9 @@ module ActiveHarness
46
49
 
47
50
  TRANSCRIPTION_PROVIDERS = {
48
51
  openai: -> { Providers::Audio::OpenAI.new },
49
- openrouter: -> { Providers::Audio::OpenRouter.new }
52
+ openrouter: -> { Providers::Audio::OpenRouter.new },
53
+ deepgram: -> { Providers::Audio::Deepgram.new },
54
+ elevenlabs: -> { Providers::Audio::ElevenLabs.new }
50
55
  }.freeze
51
56
 
52
57
  private
@@ -100,7 +105,13 @@ module ActiveHarness
100
105
 
101
106
  opts = { model: entry[:model], audio_data: File.binread(path), audio_format: format }
102
107
  language = entry[:language] || @config[:transcribe_language]
103
- opts[:language] = language if language
108
+ opts[:language] = language if language
109
+ opts[:response_format] = entry[:response_format] if entry[:response_format]
110
+ opts[:timestamp_granularities] = entry[:timestamp_granularities] if entry[:timestamp_granularities]
111
+ opts[:provider_options] = entry[:provider_options] if entry[:provider_options]
112
+ opts[:chunking_strategy] = entry[:chunking_strategy] if entry[:chunking_strategy]
113
+ opts[:known_speaker_names] = entry[:known_speaker_names] if entry[:known_speaker_names]
114
+ opts[:known_speaker_references] = entry[:known_speaker_references] if entry[:known_speaker_references]
104
115
  factory.call.call(**opts)
105
116
  end
106
117
 
@@ -140,6 +140,7 @@ module ActiveHarness
140
140
  model: entry[:model],
141
141
  error: error.message,
142
142
  error_code: error.respond_to?(:error_code) ? error.error_code : nil,
143
+ error_metadata: error.respond_to?(:metadata) ? error.metadata : nil,
143
144
  execution_time: elapsed
144
145
  }
145
146
  end
@@ -22,10 +22,15 @@ 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/ollama_cloud"
27
+ require_relative "active_harness/providers/hetzner"
25
28
  require_relative "active_harness/providers/images/openai"
26
29
  require_relative "active_harness/providers/images/openrouter"
27
30
  require_relative "active_harness/providers/audio/openai"
28
31
  require_relative "active_harness/providers/audio/openrouter"
32
+ require_relative "active_harness/providers/audio/deepgram"
33
+ require_relative "active_harness/providers/audio/elevenlabs"
29
34
  require "active_harness_pricing"
30
35
  require_relative "active_harness/memory"
31
36
  require_relative "active_harness/request"
@@ -35,7 +40,7 @@ require_relative "active_harness/pipeline"
35
40
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
36
41
 
37
42
  module ActiveHarness
38
- VERSION = "0.3.4"
43
+ VERSION = "0.3.6"
39
44
 
40
45
  class << self
41
46
  # Configure ActiveHarness.
@@ -81,6 +81,30 @@ 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
+ # TypeSafe — Jev (System One) evaluation model, direct API
86
+ # ---------------------------------------------------------------------------
87
+ # config.typesafe_api_key = ENV["TYPESAFE_API_KEY"]
88
+ # config.typesafe_api_url = "https://api.typesafe.ai/v1/systemone"
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Ollama Cloud (hosted — API key required)
92
+ # ---------------------------------------------------------------------------
93
+ # config.ollama_cloud_api_key = ENV["OLLAMA_CLOUD_API_KEY"]
94
+ # config.ollama_cloud_api_url = "https://ollama.com/v1/chat/completions"
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Hetzner Inference
98
+ # ---------------------------------------------------------------------------
99
+ # config.hetzner_api_key = ENV["HETZNER_API_KEY"]
100
+ # config.hetzner_api_url = "https://inference.hetzner.com/api/v1/chat/completions"
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Deepgram / ElevenLabs (audio transcription only)
104
+ # ---------------------------------------------------------------------------
105
+ # config.deepgram_api_key = ENV["DEEPGRAM_API_KEY"]
106
+ # config.elevenlabs_api_key = ENV["ELEVENLABS_API_KEY"]
107
+
84
108
  # ---------------------------------------------------------------------------
85
109
  # Custom providers — any OpenAI-compatible endpoint
86
110
  # Register as many as you need under arbitrary names.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-25 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -63,6 +63,8 @@ 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
@@ -73,13 +75,16 @@ files:
73
75
  - lib/active_harness/providers/gemini.rb
74
76
  - lib/active_harness/providers/gpustack.rb
75
77
  - lib/active_harness/providers/groq.rb
78
+ - lib/active_harness/providers/hetzner.rb
76
79
  - lib/active_harness/providers/images/openai.rb
77
80
  - lib/active_harness/providers/images/openrouter.rb
78
81
  - lib/active_harness/providers/mistral.rb
79
82
  - lib/active_harness/providers/ollama.rb
83
+ - lib/active_harness/providers/ollama_cloud.rb
80
84
  - lib/active_harness/providers/openai.rb
81
85
  - lib/active_harness/providers/openrouter.rb
82
86
  - lib/active_harness/providers/perplexity.rb
87
+ - lib/active_harness/providers/typesafe.rb
83
88
  - lib/active_harness/providers/vercel.rb
84
89
  - lib/active_harness/providers/vertexai.rb
85
90
  - lib/active_harness/providers/xai.rb