active_harness 0.2.40 → 0.2.42

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: 4246d31550942e31c26ec9a708f49aae5b2e0f385a32aeaa06d6d6a75f2849ee
4
- data.tar.gz: d7f12a75e7f72e3d004bfd3b64a094e99a082992fd0077e69253bddf97b919f0
3
+ metadata.gz: 64ee242555ae4c7c33965a04607e3f72c76eb83046f62aaadebf98585866b1a8
4
+ data.tar.gz: 0ec32b43d65efce4ed82c3a6ab997eb42f03720cd219baf63375fd9d4abb4316
5
5
  SHA512:
6
- metadata.gz: 81cf0f7cb7f5f54c2a996097e34461f724bd66da920db0cee57cc84f558f8430bc83ad1e914c62a56f3493e890c993c9f2dd9408c161563ae8c161f5ac03e4d5
7
- data.tar.gz: e144ed04675d274af6d66157a2f733e1cb65342adc7312b10428e9afc3c135b64af616218d6bb9ddbfb36948efd0e83b817f36654523f910c51d4797e07674e6
6
+ metadata.gz: 0cb81649fd8e9a5c8ee2f2836dbda95cf94e0ce2ad5a9f60ed5325bb5666baf0c11009578b449c0b8ba45551dd670d71b78329be71a196423d08137f660d86b3
7
+ data.tar.gz: 2b1c6257ca5e3fd16337e57a7ab5c018de1534aa98616d6cee1be19b6a767cbd7a0ec03f1bbe578400d183a1d61ae566c6eccc91587cbe276954874633a0d5e3
@@ -43,11 +43,17 @@ module ActiveHarness
43
43
  openrouter: -> { Providers::Images::OpenRouter.new }
44
44
  }.freeze
45
45
 
46
+ TRANSCRIPTION_PROVIDERS = {
47
+ openai: -> { Providers::Audio::OpenAI.new },
48
+ openrouter: -> { Providers::Audio::OpenRouter.new }
49
+ }.freeze
50
+
46
51
  private
47
52
 
48
53
  def attempt_model(entry, system_prompt)
49
54
  return attempt_via_custom_llm(entry, system_prompt) if @config[:custom_llm_backend]
50
55
  return attempt_image_model(entry, system_prompt) if @config[:image]
56
+ return attempt_transcription_model(entry) if @config[:transcribe]
51
57
 
52
58
  provider = resolve_provider(entry[:provider])
53
59
  messages = build_messages(system_prompt, @input)
@@ -78,6 +84,24 @@ module ActiveHarness
78
84
  "#{system_prompt}\n\n#{input}"
79
85
  end
80
86
 
87
+ def attempt_transcription_model(entry)
88
+ factory = TRANSCRIPTION_PROVIDERS[entry[:provider].to_sym]
89
+ raise ArgumentError, "Provider #{entry[:provider].inspect} does not support audio transcription. " \
90
+ "Supported transcription providers: #{TRANSCRIPTION_PROVIDERS.keys.join(', ')}" unless factory
91
+
92
+ path = @input.to_s
93
+ raise ArgumentError, "#{self.class.name}: audio file not found: #{path.inspect}" unless File.exist?(path)
94
+
95
+ format = File.extname(path).delete_prefix(".").downcase
96
+ raise ArgumentError, "#{self.class.name}: cannot determine audio format from #{path.inspect} " \
97
+ "— expected a file extension like .mp3/.wav/.flac/.m4a/.ogg/.webm/.aac" if format.empty?
98
+
99
+ opts = { model: entry[:model], audio_data: File.binread(path), audio_format: format }
100
+ language = entry[:language] || @config[:transcribe_language]
101
+ opts[:language] = language if language
102
+ factory.call.call(**opts)
103
+ end
104
+
81
105
  def resolve_provider(name)
82
106
  factory = PROVIDERS[name.to_sym]
83
107
  raise ArgumentError, "Unknown provider: #{name.inspect}. Supported: #{PROVIDERS.keys.join(', ')}" unless factory
@@ -0,0 +1,65 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ class << self
4
+ # Mark this agent as an audio transcription agent.
5
+ #
6
+ # class TranscriptionAgent < ActiveHarness::Agent
7
+ # transcribe true
8
+ #
9
+ # model do
10
+ # use provider: :openrouter, model: "openai/whisper-1"
11
+ # fallback provider: :openrouter, model: "deepgram/nova-3"
12
+ # end
13
+ # end
14
+ #
15
+ # @input — path to a local audio file (String), not free text
16
+ # result.output — transcribed text (String)
17
+ # result.processed — same as output (format :text default)
18
+ def transcribe(value = true)
19
+ agent_config[:transcribe] = value
20
+ end
21
+
22
+ # Default ISO-639-1 language hint for all models in this agent's chain.
23
+ # Can be overridden per-model via: use provider: :openrouter, model: "...", language: "ja"
24
+ def language(default_language)
25
+ agent_config[:transcribe_language] = default_language
26
+ end
27
+ end
28
+
29
+ alias_method :_model_list_before_transcribe, :model_list
30
+
31
+ def model_list
32
+ list = _model_list_before_transcribe
33
+ validate_transcription_models!(list) if @config[:transcribe]
34
+ list
35
+ end
36
+
37
+ private
38
+
39
+ alias_method :_normalize_input_before_transcribe, :normalize_input!
40
+
41
+ # @input is a file path for transcription agents, not free text —
42
+ # stripping/collapsing whitespace could corrupt a path. Skip it.
43
+ def normalize_input!
44
+ return if @config[:transcribe]
45
+ _normalize_input_before_transcribe
46
+ end
47
+
48
+ # Models absent from the Pricing registry are silently skipped — unknown
49
+ # models are assumed valid to avoid false negatives on new/private models.
50
+ def validate_transcription_models!(list)
51
+ list.each do |entry|
52
+ info = Pricing.find(entry[:model].to_s)
53
+ next unless info
54
+
55
+ unless info.categories.include?("transcription")
56
+ raise ArgumentError,
57
+ "#{self.class.name}: model #{entry[:model].inspect} (provider: #{entry[:provider]}) " \
58
+ "does not support audio transcription " \
59
+ "(output_modalities: #{info.output_modalities.inspect}). " \
60
+ "Use a model that has 'transcription' in output_modalities."
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -213,4 +213,5 @@ require_relative "agent/output_parser"
213
213
  require_relative "agent/custom_llm_backend"
214
214
  require_relative "agent/cost"
215
215
  require_relative "agent/image"
216
+ require_relative "agent/transcription"
216
217
 
@@ -0,0 +1,127 @@
1
+ require "uri"
2
+ require "securerandom"
3
+
4
+ module ActiveHarness
5
+ module Providers
6
+ module Audio
7
+ class OpenAI < Base
8
+ ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
9
+
10
+ # OpenAI's transcription endpoint only accepts these formats — notably
11
+ # no flac/ogg/aac, unlike OpenRouter's version of this endpoint.
12
+ CONTENT_TYPES = {
13
+ "mp3" => "audio/mpeg",
14
+ "mp4" => "audio/mp4",
15
+ "mpeg" => "audio/mpeg",
16
+ "mpga" => "audio/mpeg",
17
+ "m4a" => "audio/mp4",
18
+ "wav" => "audio/wav",
19
+ "webm" => "audio/webm"
20
+ }.freeze
21
+
22
+ # @param model [String] e.g. "whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"
23
+ # @param audio_data [String] raw binary audio bytes
24
+ # @param audio_format [String] one of CONTENT_TYPES.keys
25
+ # @param language [String] ISO-639-1 code, e.g. "en" (optional — auto-detected if omitted)
26
+ #
27
+ # Synchronous — this endpoint has no job/polling API. Unlike OpenRouter's
28
+ # transcription endpoint, OpenAI's is multipart/form-data only (no
29
+ # base64/JSON request mode).
30
+ def call(model:, audio_data:, audio_format:, language: nil, **_)
31
+ content_type = CONTENT_TYPES[audio_format]
32
+ unless content_type
33
+ raise Errors::InvalidRequestError,
34
+ "openai transcription does not support .#{audio_format} — use one of: #{CONTENT_TYPES.keys.join(', ')}"
35
+ end
36
+
37
+ boundary = SecureRandom.hex(16)
38
+ fields = { "model" => model }
39
+ fields["language"] = language if language
40
+
41
+ body = build_multipart_body(boundary, fields, audio_data, "audio.#{audio_format}", content_type)
42
+ headers = {
43
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}",
44
+ "Authorization" => "Bearer #{api_key}"
45
+ }
46
+
47
+ raw = HTTP.post(URI(ENDPOINT), headers: headers, body: body, timeout: 90)
48
+ data = parse!(raw)
49
+ handle_error!(data)
50
+
51
+ text = data["text"]
52
+ raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if text.nil?
53
+
54
+ { content: text, provider: :openai, model: model, usage: extract_transcription_usage(data) }
55
+ end
56
+
57
+ private
58
+
59
+ def build_multipart_body(boundary, fields, file_data, filename, content_type)
60
+ body = +""
61
+ fields.each do |name, value|
62
+ body << "--#{boundary}\r\n"
63
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
64
+ body << "#{value}\r\n"
65
+ end
66
+
67
+ body << "--#{boundary}\r\n"
68
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
69
+ body << "Content-Type: #{content_type}\r\n\r\n"
70
+ body << file_data
71
+ body << "\r\n--#{boundary}--\r\n"
72
+ body
73
+ end
74
+
75
+ # whisper-1 reports usage as { type: "duration", seconds: N } — no token
76
+ # counts, so there's nothing to map to input_tokens/output_tokens. Newer
77
+ # models (gpt-4o-transcribe, gpt-4o-mini-transcribe) report
78
+ # { type: "tokens", input_tokens:, output_tokens:, total_tokens:, ... }.
79
+ # Neither reports a direct dollar cost like OpenRouter does — cost is left
80
+ # to the normal per-token Pricing lookup, which returns nil for
81
+ # duration-billed models since it has no token counts to work with.
82
+ def extract_transcription_usage(data)
83
+ u = data["usage"]
84
+ return nil unless u && u["type"] == "tokens"
85
+
86
+ {
87
+ input_tokens: u["input_tokens"].to_i,
88
+ output_tokens: u["output_tokens"].to_i,
89
+ total_tokens: u["total_tokens"].to_i
90
+ }
91
+ end
92
+
93
+ def api_key
94
+ key = config.openai_api_key.to_s
95
+ raise Errors::InvalidApiKeyError, "openai_api_key is not configured" if key.empty?
96
+ key
97
+ end
98
+
99
+ def handle_error!(data)
100
+ return unless data["error"]
101
+
102
+ msg = data.dig("error", "message").to_s
103
+ code = data.dig("error", "code").to_s
104
+ type = data.dig("error", "type").to_s
105
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
106
+ metadata = nil if metadata.empty?
107
+
108
+ case code
109
+ when "invalid_api_key", "unauthorized"
110
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
111
+ when "rate_limit_exceeded"
112
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
113
+ when "content_filter"
114
+ raise Errors::SafetyBlockedError.new(msg, error_code: code, metadata: metadata)
115
+ else
116
+ case type
117
+ when "server_error"
118
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
119
+ else
120
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,85 @@
1
+ require "uri"
2
+ require "base64"
3
+
4
+ module ActiveHarness
5
+ module Providers
6
+ module Audio
7
+ class OpenRouter < Base
8
+ # Dedicated transcription endpoint — distinct from the chat completions
9
+ # endpoint used by text/image generation on OpenRouter.
10
+ ENDPOINT = "https://openrouter.ai/api/v1/audio/transcriptions"
11
+
12
+ # @param model [String] e.g. "openai/whisper-1", "deepgram/nova-3"
13
+ # @param audio_data [String] raw binary audio bytes
14
+ # @param audio_format [String] "wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"
15
+ # @param language [String] ISO-639-1 code, e.g. "en" (optional — auto-detected if omitted)
16
+ #
17
+ # Synchronous call — OpenRouter's transcription endpoint has no job/polling
18
+ # API. Upstream providers time out after ~60s per request, so long audio
19
+ # should be split into shorter chunks by the caller before transcribing.
20
+ def call(model:, audio_data:, audio_format:, language: nil, **_)
21
+ headers = {
22
+ "Content-Type" => "application/json",
23
+ "Authorization" => "Bearer #{api_key}"
24
+ }
25
+ referer = config.openrouter_http_referer.to_s
26
+ headers["HTTP-Referer"] = referer unless referer.empty?
27
+
28
+ body = {
29
+ model: model,
30
+ input_audio: { data: Base64.strict_encode64(audio_data), format: audio_format }
31
+ }
32
+ body[:language] = language if language
33
+
34
+ raw = post_json(URI(ENDPOINT), headers: headers, body: body, timeout: 90)
35
+ data = parse!(raw)
36
+ handle_error!(data)
37
+
38
+ text = data["text"]
39
+ raise Errors::ProviderError, "No transcription text in response: #{data.keys}" if text.nil?
40
+
41
+ { content: text, provider: :openrouter, model: model, usage: extract_transcription_usage(data) }
42
+ end
43
+
44
+ private
45
+
46
+ # OpenRouter's transcription usage shape differs from its chat/image usage
47
+ # shape (input_tokens/output_tokens directly, not prompt_tokens/completion_tokens).
48
+ def extract_transcription_usage(data)
49
+ u = data["usage"]
50
+ return nil unless u
51
+
52
+ result = {
53
+ input_tokens: u["input_tokens"].to_i,
54
+ output_tokens: u["output_tokens"].to_i,
55
+ total_tokens: u["total_tokens"].to_i
56
+ }
57
+ result[:provider_cost] = u["cost"].to_f if u.key?("cost")
58
+ result
59
+ end
60
+
61
+ def api_key
62
+ key = config.openrouter_api_key.to_s
63
+ raise Errors::InvalidApiKeyError, "openrouter_api_key is not configured" if key.empty?
64
+ key
65
+ end
66
+
67
+ def handle_error!(data)
68
+ return unless data["error"]
69
+
70
+ msg = data.dig("error", "message").to_s
71
+ code = data.dig("error", "code").to_s
72
+ metadata = data.dig("error", "metadata")
73
+
74
+ case code
75
+ when "401" then raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
76
+ when "402", "429" then raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
77
+ when "500", "502",
78
+ "503", "504" then raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
79
+ else raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -23,6 +23,8 @@ require_relative "active_harness/providers/vertexai"
23
23
  require_relative "active_harness/providers/custom"
24
24
  require_relative "active_harness/providers/images/openai"
25
25
  require_relative "active_harness/providers/images/openrouter"
26
+ require_relative "active_harness/providers/audio/openai"
27
+ require_relative "active_harness/providers/audio/openrouter"
26
28
  require "active_harness_pricing"
27
29
  require_relative "active_harness/memory"
28
30
  require_relative "active_harness/agent"
@@ -32,7 +34,7 @@ require_relative "active_harness/pipeline"
32
34
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
33
35
 
34
36
  module ActiveHarness
35
- VERSION = "0.2.40"
37
+ VERSION = "0.2.42"
36
38
 
37
39
  class << self
38
40
  # Configure ActiveHarness.
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.2.40
4
+ version: 0.2.42
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-07-19 00:00:00.000000000 Z
11
+ date: 2026-07-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -55,6 +55,7 @@ files:
55
55
  - lib/active_harness/agent/output_parser.rb
56
56
  - lib/active_harness/agent/prompt.rb
57
57
  - lib/active_harness/agent/providers.rb
58
+ - lib/active_harness/agent/transcription.rb
58
59
  - lib/active_harness/configuration.rb
59
60
  - lib/active_harness/core/errors.rb
60
61
  - lib/active_harness/core/hooks.rb
@@ -72,6 +73,8 @@ files:
72
73
  - lib/active_harness/pipeline/step.rb
73
74
  - lib/active_harness/providers/PROVIDER_CONTRACT.md
74
75
  - lib/active_harness/providers/anthropic.rb
76
+ - lib/active_harness/providers/audio/openai.rb
77
+ - lib/active_harness/providers/audio/openrouter.rb
75
78
  - lib/active_harness/providers/azure.rb
76
79
  - lib/active_harness/providers/base.rb
77
80
  - lib/active_harness/providers/bedrock.rb