active_harness 0.2.41 → 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: e575886969a297ab3688f08a326e0290b1f7c433322c7a05aab8f183178e441e
4
- data.tar.gz: 8b73cfb142335117fd7bde7c6da6ebca88f7218906450a1897b2cd42ea9b37d5
3
+ metadata.gz: 64ee242555ae4c7c33965a04607e3f72c76eb83046f62aaadebf98585866b1a8
4
+ data.tar.gz: 0ec32b43d65efce4ed82c3a6ab997eb42f03720cd219baf63375fd9d4abb4316
5
5
  SHA512:
6
- metadata.gz: 51060b54a300332514f5993d83b5666416da70dc51493d06259106909c17a96262d28991290cbcc51b4018f324e82d06f274cd292d81ad65ad9b99a33a82060d
7
- data.tar.gz: f4041988b036d9b252c0081e3e664febabaf72cbc69995629f21b6b6711c7a4283a1d9e30fb388f0bbde0a127c940e54cd3a4b325978798a4d090e93ecf639dc
6
+ metadata.gz: 0cb81649fd8e9a5c8ee2f2836dbda95cf94e0ce2ad5a9f60ed5325bb5666baf0c11009578b449c0b8ba45551dd670d71b78329be71a196423d08137f660d86b3
7
+ data.tar.gz: 2b1c6257ca5e3fd16337e57a7ab5c018de1534aa98616d6cee1be19b6a767cbd7a0ec03f1bbe578400d183a1d61ae566c6eccc91587cbe276954874633a0d5e3
@@ -44,6 +44,7 @@ module ActiveHarness
44
44
  }.freeze
45
45
 
46
46
  TRANSCRIPTION_PROVIDERS = {
47
+ openai: -> { Providers::Audio::OpenAI.new },
47
48
  openrouter: -> { Providers::Audio::OpenRouter.new }
48
49
  }.freeze
49
50
 
@@ -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
@@ -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/images/openai"
25
25
  require_relative "active_harness/providers/images/openrouter"
26
+ require_relative "active_harness/providers/audio/openai"
26
27
  require_relative "active_harness/providers/audio/openrouter"
27
28
  require "active_harness_pricing"
28
29
  require_relative "active_harness/memory"
@@ -33,7 +34,7 @@ require_relative "active_harness/pipeline"
33
34
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
34
35
 
35
36
  module ActiveHarness
36
- VERSION = "0.2.41"
37
+ VERSION = "0.2.42"
37
38
 
38
39
  class << self
39
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.41
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
@@ -73,6 +73,7 @@ files:
73
73
  - lib/active_harness/pipeline/step.rb
74
74
  - lib/active_harness/providers/PROVIDER_CONTRACT.md
75
75
  - lib/active_harness/providers/anthropic.rb
76
+ - lib/active_harness/providers/audio/openai.rb
76
77
  - lib/active_harness/providers/audio/openrouter.rb
77
78
  - lib/active_harness/providers/azure.rb
78
79
  - lib/active_harness/providers/base.rb