active_harness 0.2.39 → 0.2.41

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: 656a10b468592b8e0c53c2c93a8fbf7b8536420f94bd0cb7a61255d432ee78ed
4
- data.tar.gz: 29de2c821b23ba363ef1596fb545035fbd1191ca6487161f6b9f30b39fe960db
3
+ metadata.gz: e575886969a297ab3688f08a326e0290b1f7c433322c7a05aab8f183178e441e
4
+ data.tar.gz: 8b73cfb142335117fd7bde7c6da6ebca88f7218906450a1897b2cd42ea9b37d5
5
5
  SHA512:
6
- metadata.gz: 9d0d4c4114ed028a603a783841c5205163f1f02203e7c010d5c806a8e673644b391cbfb636bbb1c4d77eb40a807907e63380213b087ab3c5515491c1c2c64fcf
7
- data.tar.gz: 84b154cb18717e6183337d728a6eb3ec9d34753856d6acc8fbf96c90e06a6e0db799b961b3f8d5bd7dc3984797bed401429a3110573243379eb471fcd84f160b
6
+ metadata.gz: 51060b54a300332514f5993d83b5666416da70dc51493d06259106909c17a96262d28991290cbcc51b4018f324e82d06f274cd292d81ad65ad9b99a33a82060d
7
+ data.tar.gz: f4041988b036d9b252c0081e3e664febabaf72cbc69995629f21b6b6711c7a4283a1d9e30fb388f0bbde0a127c940e54cd3a4b325978798a4d090e93ecf639dc
@@ -7,6 +7,10 @@ module ActiveHarness
7
7
  # image true
8
8
  # size "1024x1024"
9
9
  #
10
+ # # Optional: base style/formatting guidance, prepended to @input
11
+ # # to form the final image prompt (see Agent#build_image_prompt).
12
+ # system_prompt "Watercolor style, soft pastel colors, no text overlays."
13
+ #
10
14
  # model do
11
15
  # use provider: :openrouter, model: "openai/gpt-5-image-mini"
12
16
  # fallback provider: :openai, model: "gpt-image-1"
@@ -43,11 +43,16 @@ module ActiveHarness
43
43
  openrouter: -> { Providers::Images::OpenRouter.new }
44
44
  }.freeze
45
45
 
46
+ TRANSCRIPTION_PROVIDERS = {
47
+ openrouter: -> { Providers::Audio::OpenRouter.new }
48
+ }.freeze
49
+
46
50
  private
47
51
 
48
52
  def attempt_model(entry, system_prompt)
49
53
  return attempt_via_custom_llm(entry, system_prompt) if @config[:custom_llm_backend]
50
- return attempt_image_model(entry) if @config[:image]
54
+ return attempt_image_model(entry, system_prompt) if @config[:image]
55
+ return attempt_transcription_model(entry) if @config[:transcribe]
51
56
 
52
57
  provider = resolve_provider(entry[:provider])
53
58
  messages = build_messages(system_prompt, @input)
@@ -58,17 +63,44 @@ module ActiveHarness
58
63
  provider.call(**opts)
59
64
  end
60
65
 
61
- def attempt_image_model(entry)
66
+ def attempt_image_model(entry, system_prompt)
62
67
  factory = IMAGE_PROVIDERS[entry[:provider].to_sym]
63
68
  raise ArgumentError, "Provider #{entry[:provider].inspect} does not support image generation. " \
64
69
  "Supported image providers: #{IMAGE_PROVIDERS.keys.join(', ')}" unless factory
65
70
 
66
71
  size = entry[:size] || @config[:image_size] || "1024x1024"
67
- opts = { model: entry[:model], prompt: @input.to_s, size: size }
72
+ opts = { model: entry[:model], prompt: build_image_prompt(system_prompt, @input.to_s), size: size }
68
73
  opts[:quality] = entry[:quality] if entry[:quality]
69
74
  factory.call.call(**opts)
70
75
  end
71
76
 
77
+ # Image APIs take a single prompt string (no system/user role split), so the
78
+ # system prompt — e.g. base style/formatting guidance — is prepended to the
79
+ # user's input rather than sent as a separate message.
80
+ def build_image_prompt(system_prompt, input)
81
+ return input if system_prompt.nil? || system_prompt.to_s.strip.empty?
82
+
83
+ "#{system_prompt}\n\n#{input}"
84
+ end
85
+
86
+ def attempt_transcription_model(entry)
87
+ factory = TRANSCRIPTION_PROVIDERS[entry[:provider].to_sym]
88
+ raise ArgumentError, "Provider #{entry[:provider].inspect} does not support audio transcription. " \
89
+ "Supported transcription providers: #{TRANSCRIPTION_PROVIDERS.keys.join(', ')}" unless factory
90
+
91
+ path = @input.to_s
92
+ raise ArgumentError, "#{self.class.name}: audio file not found: #{path.inspect}" unless File.exist?(path)
93
+
94
+ format = File.extname(path).delete_prefix(".").downcase
95
+ raise ArgumentError, "#{self.class.name}: cannot determine audio format from #{path.inspect} " \
96
+ "— expected a file extension like .mp3/.wav/.flac/.m4a/.ogg/.webm/.aac" if format.empty?
97
+
98
+ opts = { model: entry[:model], audio_data: File.binread(path), audio_format: format }
99
+ language = entry[:language] || @config[:transcribe_language]
100
+ opts[:language] = language if language
101
+ factory.call.call(**opts)
102
+ end
103
+
72
104
  def resolve_provider(name)
73
105
  factory = PROVIDERS[name.to_sym]
74
106
  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
 
@@ -10,14 +10,21 @@ module ActiveHarness
10
10
  # It does NOT automatically inject history into LLM messages.
11
11
  # Injection is always manual — you control when and how context is used.
12
12
  #
13
- # --- Recording (automatic) ---
13
+ # --- Recording ---
14
14
  #
15
- # When a Memory object is passed to an agent, the agent automatically
16
- # saves each successful turn (request + response) after the call.
15
+ # Passing a Memory object to Agent.call(memory:) does NOT by itself save
16
+ # anything Agent never calls #load or #record on it. Loading and
17
+ # recording turns for a bare agent call is entirely manual (e.g. via
18
+ # before_call/after_call hooks calling @memory.load / @memory.record).
19
+ #
20
+ # Pipeline is the one place recording is automatic: when a Memory is
21
+ # passed to Pipeline#call, the pipeline itself calls #load before running
22
+ # steps and #record after a successful run — independent of any hooks the
23
+ # individual agents define.
17
24
  #
18
25
  # memory = ActiveHarness::Memory.new(session_id: "u42", depth: 8)
19
26
  # SupportAgent.call(input: "Hello", memory: memory)
20
- # # => turn is saved to storage/ai/memory/u42.json
27
+ # # => nothing is saved unless SupportAgent's own hooks record it
21
28
  #
22
29
  # --- Manual injection patterns ---
23
30
  #
@@ -25,7 +25,7 @@ pipeline.call
25
25
 
26
26
  pipeline.output # => final payload string (nil if stopped)
27
27
  pipeline.stopped? # => false
28
- pipeline.step_results # => { translate: <Result>, injection_guard: <Result>, ... }
28
+ pipeline.steps.to_a # => [[:translate, <TranslationAgent>, <Result>], [:injection_guard, <InjectionGuardAgent>, <Result>], ...]
29
29
  ```
30
30
 
31
31
  ## Step types
@@ -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,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/openrouter"
26
27
  require "active_harness_pricing"
27
28
  require_relative "active_harness/memory"
28
29
  require_relative "active_harness/agent"
@@ -32,7 +33,7 @@ require_relative "active_harness/pipeline"
32
33
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
33
34
 
34
35
  module ActiveHarness
35
- VERSION = "0.2.39"
36
+ VERSION = "0.2.41"
36
37
 
37
38
  class << self
38
39
  # 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.39
4
+ version: 0.2.41
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-06-15 00:00:00.000000000 Z
11
+ date: 2026-07-19 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,7 @@ 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/openrouter.rb
75
77
  - lib/active_harness/providers/azure.rb
76
78
  - lib/active_harness/providers/base.rb
77
79
  - lib/active_harness/providers/bedrock.rb