active_harness 0.2.41 → 0.3.0

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_harness/configuration.rb +3 -1
  3. data/lib/active_harness/core/errors.rb +2 -2
  4. data/lib/active_harness/core/hooks.rb +2 -2
  5. data/lib/active_harness/memory.rb +11 -11
  6. data/lib/active_harness/pipeline/README.md +15 -15
  7. data/lib/active_harness/pipeline/step.rb +1 -1
  8. data/lib/active_harness/pipeline.rb +18 -18
  9. data/lib/active_harness/providers/PROVIDER_CONTRACT.md +1 -1
  10. data/lib/active_harness/providers/audio/openai.rb +127 -0
  11. data/lib/active_harness/providers/azure.rb +1 -1
  12. data/lib/active_harness/providers/bedrock.rb +2 -2
  13. data/lib/active_harness/providers/custom.rb +1 -1
  14. data/lib/active_harness/providers/images/openrouter.rb +49 -1
  15. data/lib/active_harness/providers/vertexai.rb +2 -2
  16. data/lib/active_harness/railtie.rb +1 -1
  17. data/lib/active_harness/{agent → request}/cost.rb +1 -1
  18. data/lib/active_harness/{agent → request}/custom_llm_backend.rb +4 -4
  19. data/lib/active_harness/{agent → request}/hooks.rb +4 -4
  20. data/lib/active_harness/{agent → request}/image.rb +7 -7
  21. data/lib/active_harness/{agent → request}/models.rb +15 -15
  22. data/lib/active_harness/{agent → request}/output_parser.rb +1 -1
  23. data/lib/active_harness/{agent → request}/prompt.rb +7 -7
  24. data/lib/active_harness/{agent → request}/providers.rb +2 -1
  25. data/lib/active_harness/{agent → request}/transcription.rb +7 -7
  26. data/lib/active_harness/{agent.rb → request.rb} +19 -19
  27. data/lib/active_harness/result.rb +3 -3
  28. data/lib/active_harness/tribunal/dsl.rb +7 -7
  29. data/lib/active_harness/tribunal/hooks.rb +9 -9
  30. data/lib/active_harness/tribunal.rb +62 -62
  31. data/lib/active_harness.rb +3 -2
  32. data/lib/generators/active_harness/install/install_generator.rb +1 -1
  33. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +3 -3
  34. data/lib/generators/active_harness/install/templates/initializers/active_harness.rb +2 -2
  35. data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +2 -2
  36. data/lib/generators/active_harness/install/templates/{agents/support_guard_agent.rb → requests/support_guard_request.rb} +1 -1
  37. data/lib/generators/active_harness/install/templates/{agents/support_agent.rb → requests/support_request.rb} +1 -1
  38. data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +3 -3
  39. data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +1 -1
  40. data/lib/generators/active_harness/request/request_generator.rb +16 -0
  41. data/lib/generators/active_harness/{agent/templates/agent.rb.tt → request/templates/request.rb.tt} +1 -1
  42. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +1 -1
  43. metadata +18 -17
  44. data/lib/generators/active_harness/agent/agent_generator.rb +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e575886969a297ab3688f08a326e0290b1f7c433322c7a05aab8f183178e441e
4
- data.tar.gz: 8b73cfb142335117fd7bde7c6da6ebca88f7218906450a1897b2cd42ea9b37d5
3
+ metadata.gz: 779258acc7a5947e923ac8f5ad262f653e96ad0509585f8a309e362bc0ee3703
4
+ data.tar.gz: 1364d064abfe80ccdd5fdadd7bfb6d55396ec2ceaf7680d0bdd1373011cd9923
5
5
  SHA512:
6
- metadata.gz: 51060b54a300332514f5993d83b5666416da70dc51493d06259106909c17a96262d28991290cbcc51b4018f324e82d06f274cd292d81ad65ad9b99a33a82060d
7
- data.tar.gz: f4041988b036d9b252c0081e3e664febabaf72cbc69995629f21b6b6711c7a4283a1d9e30fb388f0bbde0a127c940e54cd3a4b325978798a4d090e93ecf639dc
6
+ metadata.gz: 94bf07f8a86b78da1f5507e02644c1a6bb2310074ea3b02eca476164480a546c286d12c699ee97743a4e8b9e1c52fd74f1fc1f33e90f1302c4235b0009e2bbd6
7
+ data.tar.gz: 3355b0cb47da66faa4164f4712ba7f5e9e0e23d405a00177599ce26d34255ea3e80b29effa8f9fa7d5bfc34e6dfb45005e0a6281df70dfa847db965e7039e54e
@@ -52,6 +52,7 @@ module ActiveHarness
52
52
  # -------------------------------------------------------------------------
53
53
  attr_accessor :openrouter_api_key
54
54
  attr_accessor :openrouter_api_url
55
+ attr_accessor :openrouter_images_api_url
55
56
  attr_accessor :openrouter_http_referer
56
57
 
57
58
  # -------------------------------------------------------------------------
@@ -111,7 +112,7 @@ module ActiveHarness
111
112
  # config.custom["SecondProvider"]["api_key"] = ENV["SECOND_API_KEY"]
112
113
  # end
113
114
  #
114
- # Use in an agent:
115
+ # Use in a request:
115
116
  # model do
116
117
  # use provider: :custom, name: "MyLocal", model: "llama3.2"
117
118
  # fallback provider: :custom, name: "SecondProvider", model: "mixtral"
@@ -144,6 +145,7 @@ module ActiveHarness
144
145
 
145
146
  @openrouter_api_key = ENV["OPENROUTER_API_KEY"]
146
147
  @openrouter_api_url = "https://openrouter.ai/api/v1/chat/completions"
148
+ @openrouter_images_api_url = ENV.fetch("OPENROUTER_IMAGES_API_URL", "https://openrouter.ai/api/v1/images")
147
149
  @openrouter_http_referer = "https://github.com/the-teacher/ActiveHarness"
148
150
 
149
151
  @xai_api_key = ENV["XAI_API_KEY"]
@@ -5,8 +5,8 @@ module ActiveHarness
5
5
  # Raised when all models in the chain fail
6
6
  AllModelsFailed = Class.new(Error)
7
7
 
8
- # Raised by Tribunal when every agent fails or times out
9
- AllAgentsFailed = Class.new(Error)
8
+ # Raised by Tribunal when every request fails or times out
9
+ AllRequestsFailed = Class.new(Error)
10
10
 
11
11
  # Base for all provider-level failures — carries an optional error_code and metadata
12
12
  class ProviderError < Error
@@ -1,13 +1,13 @@
1
1
  module ActiveHarness
2
2
  module Core
3
- # Shared hook execution logic included by Agent, Tribunal, and Pipeline.
3
+ # Shared hook execution logic included by Request, Tribunal, and Pipeline.
4
4
  #
5
5
  # Hooks are stored in arrays so multiple +on+/+before+/+after+/+callback+
6
6
  # calls with the same event name accumulate — later registrations append
7
7
  # rather than overwrite. This lets modules register default hooks without
8
8
  # blocking user-defined hooks on the same event.
9
9
  #
10
- # class MyAgent < ActiveHarness::Agent
10
+ # class MyRequest < ActiveHarness::Request
11
11
  # include SomeTracingConcern # registers before(:call) internally
12
12
  # before(:call) { ... } # appends — both hooks run in order
13
13
  # end
@@ -4,7 +4,7 @@ require_relative "memory/adapter/postgresql"
4
4
  require_relative "memory/adapter/sqlite"
5
5
 
6
6
  module ActiveHarness
7
- # Conversational memory for agents.
7
+ # Conversational memory for requests.
8
8
  #
9
9
  # Memory only records the history of request/response turns.
10
10
  # It does NOT automatically inject history into LLM messages.
@@ -12,19 +12,19 @@ module ActiveHarness
12
12
  #
13
13
  # --- Recording ---
14
14
  #
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
15
+ # Passing a Memory object to Request.call(memory:) does NOT by itself save
16
+ # anything — Request never calls #load or #record on it. Loading and
17
+ # recording turns for a bare request call is entirely manual (e.g. via
18
18
  # before_call/after_call hooks calling @memory.load / @memory.record).
19
19
  #
20
20
  # Pipeline is the one place recording is automatic: when a Memory is
21
21
  # passed to Pipeline#call, the pipeline itself calls #load before running
22
22
  # steps and #record after a successful run — independent of any hooks the
23
- # individual agents define.
23
+ # individual requests define.
24
24
  #
25
25
  # memory = ActiveHarness::Memory.new(session_id: "u42", depth: 8)
26
- # SupportAgent.call(input: "Hello", memory: memory)
27
- # # => nothing is saved unless SupportAgent's own hooks record it
26
+ # SupportRequest.call(input: "Hello", memory: memory)
27
+ # # => nothing is saved unless SupportRequest's own hooks record it
28
28
  #
29
29
  # --- Manual injection patterns ---
30
30
  #
@@ -76,7 +76,7 @@ module ActiveHarness
76
76
  # adapter — :json_file (default), or an adapter instance
77
77
  # enabled — false disables all reads and writes (no-op mode)
78
78
  # read_only — true: load history but never write new turns
79
- # namespace — isolates history per-agent within a session
79
+ # namespace — isolates history per-request within a session
80
80
  # on_trim — Proc called with trimmed turns on storage trim
81
81
  # async — write to adapter in a background thread
82
82
  # **adapter_opts — forwarded to the adapter (path, storage_size, etc.)
@@ -117,7 +117,7 @@ module ActiveHarness
117
117
  # -------------------------------------------------------------------------
118
118
 
119
119
  # Load history from storage into RAM.
120
- # Called automatically by the agent at the start of #call.
120
+ # Called automatically by the request at the start of #call.
121
121
  # After loading, history is available via #turns and #to_messages
122
122
  # for manual injection in hooks or prompt classes.
123
123
  def load
@@ -129,7 +129,7 @@ module ActiveHarness
129
129
  @loaded = true
130
130
  end
131
131
 
132
- # Record a turn after a successful agent call.
132
+ # Record a turn after a successful request call.
133
133
  def record(request:, response:, **meta)
134
134
  return unless @enabled
135
135
  return if @read_only
@@ -151,7 +151,7 @@ module ActiveHarness
151
151
 
152
152
  # Returns messages array for LLM consumption, respecting depth.
153
153
  # Optional filters:
154
- # filter: ->(turn) { turn[:agent] == "SupportAgent" }
154
+ # filter: ->(turn) { turn[:request_class] == "SupportRequest" }
155
155
  # since: Time.now - 3600
156
156
  # token_budget: 4000 # rough limit (chars / 4 estimate); trims oldest turns first
157
157
  def to_messages(filter: nil, since: nil, token_budget: nil)
@@ -1,16 +1,16 @@
1
1
  # Pipeline
2
2
 
3
- A pipeline chains multiple agents and tribunals into a sequential workflow.
3
+ A pipeline chains multiple requests and tribunals into a sequential workflow.
4
4
  Each step receives the current payload, can transform it, and can stop the pipeline early.
5
5
 
6
6
  ## Basic usage
7
7
 
8
8
  ```ruby
9
9
  class SupportPipeline < ActiveHarness::Pipeline
10
- step :translate, TranslationAgent
10
+ step :translate, TranslationRequest
11
11
 
12
12
  step :injection_guard do
13
- use InjectionGuardAgent
13
+ use InjectionGuardRequest
14
14
  stop_if ->(result) { result.processed["detected"] == true }
15
15
  end
16
16
 
@@ -25,17 +25,17 @@ pipeline.call
25
25
 
26
26
  pipeline.output # => final payload string (nil if stopped)
27
27
  pipeline.stopped? # => false
28
- pipeline.steps.to_a # => [[:translate, <TranslationAgent>, <Result>], [:injection_guard, <InjectionGuardAgent>, <Result>], ...]
28
+ pipeline.steps.to_a # => [[:translate, <TranslationRequest>, <Result>], [:injection_guard, <InjectionGuardRequest>, <Result>], ...]
29
29
  ```
30
30
 
31
31
  ## Step types
32
32
 
33
33
  There are two kinds of classes a step can use.
34
34
 
35
- **Agent step** — runs the agent, takes `result.output` as the new payload:
35
+ **Request step** — runs the request, takes `result.output` as the new payload:
36
36
 
37
37
  ```ruby
38
- step :translate, TranslationAgent
38
+ step :translate, TranslationRequest
39
39
  ```
40
40
 
41
41
  **Tribunal step** — runs the tribunal, returns a `Result` with `processed["verdict"]`.
@@ -54,8 +54,8 @@ The payload starts as the value passed to `input:` and flows through the steps:
54
54
 
55
55
  | Condition | Payload after step |
56
56
  |-----------|--------------------|
57
- | Agent step, no `stop_if` | Updated to `result.output` |
58
- | Agent step with `stop_if` | Unchanged (guard step) |
57
+ | Request step, no `stop_if` | Updated to `result.output` |
58
+ | Request step with `stop_if` | Unchanged (guard step) |
59
59
  | Tribunal step | Unchanged |
60
60
 
61
61
  After each step the result is also stored in `context[step_name]`,
@@ -67,7 +67,7 @@ Any step can stop the pipeline by defining `stop_if`:
67
67
 
68
68
  ```ruby
69
69
  step :injection_guard do
70
- use InjectionGuardAgent
70
+ use InjectionGuardRequest
71
71
  stop_if ->(result) { result.processed["detected"] == true }
72
72
  end
73
73
  ```
@@ -82,7 +82,7 @@ When the condition is true:
82
82
 
83
83
  ```ruby
84
84
  class SupportPipeline < ActiveHarness::Pipeline
85
- on_agent_event do |event, result| ... end # fires for every agent inside
85
+ on_request_event do |event, result| ... end # fires for every request inside
86
86
  on_tribunal_event do |event, verdict| ... end # fires for every tribunal inside
87
87
  on_pipeline_event do |event, *args| ... end # :before_step, :after_step, :stopped, :complete
88
88
  end
@@ -93,7 +93,7 @@ Runtime streams can be passed at construction time:
93
93
  ```ruby
94
94
  SupportPipeline.new(
95
95
  input: "...",
96
- streams: { token: token_lambda, agent: agent_lambda }
96
+ streams: { token: token_lambda, request: request_lambda }
97
97
  )
98
98
  ```
99
99
 
@@ -112,7 +112,7 @@ SupportPipeline.new(input: "...", memory: mem).call
112
112
 
113
113
  ## Proposal: universal step interface
114
114
 
115
- Currently `Pipeline::Step` special-cases two concrete classes: `Agent` and `Tribunal`.
115
+ Currently `Pipeline::Step` special-cases two concrete classes: `Request` and `Tribunal`.
116
116
  This section explores making the pipeline open to any entity — a plain Ruby object,
117
117
  a lambda, a nested pipeline, an HTTP call, a cache lookup — with no inheritance required.
118
118
 
@@ -149,7 +149,7 @@ Pipeline::StepResult = Struct.new(:output, :stop, keyword_init: true) do
149
149
  end
150
150
  ```
151
151
 
152
- **Pros:** almost no change to existing code; agents and tribunals get thin adapters.
152
+ **Pros:** almost no change to existing code; requests and tribunals get thin adapters.
153
153
  **Cons:** every custom step must construct `StepResult`; slightly more boilerplate.
154
154
 
155
155
  ---
@@ -208,7 +208,7 @@ step :length_guard, LengthGuard.new
208
208
 
209
209
  **Pros:** stateless, composable, easy to test (`call(env)` in one line); nested
210
210
  pipelines become trivial — a pipeline is just another object with `call(env)`.
211
- **Cons:** largest departure from the current API; requires migrating Agent/Tribunal wrappers.
211
+ **Cons:** largest departure from the current API; requires migrating Request/Tribunal wrappers.
212
212
 
213
213
  ---
214
214
 
@@ -229,7 +229,7 @@ class EnrichStep
229
229
  end
230
230
  ```
231
231
 
232
- Agents and Tribunals include `Callable` automatically, so they work as before.
232
+ Requests and Tribunals include `Callable` automatically, so they work as before.
233
233
  Any plain class can opt in with one `include`.
234
234
 
235
235
  **Pros:** clear opt-in contract; helpers reduce boilerplate; IDE-friendly.
@@ -10,7 +10,7 @@ module ActiveHarness
10
10
  instance_eval(&block) if block_given?
11
11
  end
12
12
 
13
- # DSL: use TranslationAgent / SafetyTribunal / NestedPipeline
13
+ # DSL: use TranslationRequest / SafetyTribunal / NestedPipeline
14
14
  def use(klass)
15
15
  @executor = klass
16
16
  end
@@ -1,16 +1,16 @@
1
1
  module ActiveHarness
2
- # Sequential pipeline that chains agents and tribunals.
2
+ # Sequential pipeline that chains requests and tribunals.
3
3
  # Each step receives the current payload and can transform it or stop the pipeline.
4
4
  #
5
5
  # Usage (subclass with DSL):
6
6
  #
7
7
  # class SupportPipeline < ActiveHarness::Pipeline
8
8
  # step :injection_guard do
9
- # use InjectionGuardAgent
9
+ # use InjectionGuardRequest
10
10
  # stop_if ->(result) { result.processed["detected"] == true }
11
11
  # end
12
12
  #
13
- # step :translate, TranslationAgent # shorthand — no stop_if
13
+ # step :translate, TranslationRequest # shorthand — no stop_if
14
14
  #
15
15
  # step :safety_tribunal do
16
16
  # use SafetyTribunal
@@ -38,12 +38,12 @@ module ActiveHarness
38
38
  class << self
39
39
  # Define a step in the pipeline.
40
40
  #
41
- # Shorthand (agent only, no stop_if):
42
- # step :translate, TranslationAgent
41
+ # Shorthand (request only, no stop_if):
42
+ # step :translate, TranslationRequest
43
43
  #
44
44
  # Full block form:
45
45
  # step :injection_guard do
46
- # use InjectionGuardAgent
46
+ # use InjectionGuardRequest
47
47
  # stop_if ->(result) { result.processed["detected"] == true }
48
48
  # end
49
49
  def step(name, executor = nil, &block)
@@ -63,13 +63,13 @@ module ActiveHarness
63
63
  end
64
64
 
65
65
  # Class-level event stream handlers — fired for every matching event from
66
- # any agent or tribunal executed within this pipeline (including agents
66
+ # any request or tribunal executed within this pipeline (including requests
67
67
  # running inside tribunals). Multiple blocks can be registered; all fire.
68
68
  #
69
69
  # The handler receives (event, *args) — already scoped to the source.
70
70
  #
71
- # on_agent_event do |event, result|
72
- # Rails.logger.info "[Agent #{event}] #{result.model}" if event == :after_call
71
+ # on_request_event do |event, result|
72
+ # Rails.logger.info "[Request #{event}] #{result.model}" if event == :after_call
73
73
  # end
74
74
  #
75
75
  # on_tribunal_event do |event, verdict|
@@ -79,8 +79,8 @@ module ActiveHarness
79
79
  # on_pipeline_event do |event, step_name, _data|
80
80
  # Rails.logger.info "[Pipeline #{event}] step=#{step_name}"
81
81
  # end
82
- def on_agent_event(&block)
83
- (pipeline_config[:streams][:agent] ||= []) << block
82
+ def on_request_event(&block)
83
+ (pipeline_config[:streams][:request] ||= []) << block
84
84
  end
85
85
 
86
86
  def on_tribunal_event(&block)
@@ -157,7 +157,7 @@ module ActiveHarness
157
157
  # pipeline.steps { |name, executor, result| }
158
158
  #
159
159
  # name — step name symbol (:translate, :guard, …)
160
- # executor — the instance that ran (TranslationAgent instance, …)
160
+ # executor — the instance that ran (TranslationRequest instance, …)
161
161
  # result — Result struct (output, processed, usage, model, …)
162
162
  #
163
163
  # pipeline.steps.map { |name, executor, result| [name, result.output] }
@@ -173,7 +173,7 @@ module ActiveHarness
173
173
  end
174
174
 
175
175
  # Wraps pipeline outcome into a Result so a pipeline can be used as a step
176
- # inside another pipeline, matching the same interface as Agent and Tribunal.
176
+ # inside another pipeline, matching the same interface as Request and Tribunal.
177
177
  #
178
178
  # output — final payload (nil when stopped)
179
179
  # processed — { "stopped" => bool, "stopped_at" => step_name_string_or_nil }
@@ -187,7 +187,7 @@ module ActiveHarness
187
187
  end
188
188
 
189
189
  # Execute all steps sequentially. Returns self for chaining.
190
- # Accepts optional input, token, stream to match the Agent/Tribunal call interface.
190
+ # Accepts optional input, token, stream to match the Request/Tribunal call interface.
191
191
  def call(input = nil, token: nil, stream: nil)
192
192
  if input
193
193
  @original_input = input
@@ -245,24 +245,24 @@ module ActiveHarness
245
245
  private
246
246
 
247
247
  # Combines a runtime-passed stream lambda with class-level handler blocks
248
- # registered via on_agent_event / on_tribunal_event / on_pipeline_event.
248
+ # registered via on_request_event / on_tribunal_event / on_pipeline_event.
249
249
  # Returns nil when there are no handlers at all.
250
250
  #
251
251
  # Class-level handlers receive (event, *args) — already scoped to source.
252
252
  # Runtime lambda receives (source, event, *args).
253
253
  # instance_exec lets class-level blocks access pipeline instance variables.
254
254
  def merge_stream(passed_in, class_handlers)
255
- agent_handlers = Array(class_handlers[:agent]).compact
255
+ request_handlers = Array(class_handlers[:request]).compact
256
256
  tribunal_handlers = Array(class_handlers[:tribunal]).compact
257
257
  pipeline_handlers = Array(class_handlers[:pipeline]).compact
258
258
 
259
- has_class_handlers = agent_handlers.any? || tribunal_handlers.any? || pipeline_handlers.any?
259
+ has_class_handlers = request_handlers.any? || tribunal_handlers.any? || pipeline_handlers.any?
260
260
  return passed_in unless has_class_handlers
261
261
 
262
262
  pipeline_instance = self
263
263
  ->(source, event, *args) {
264
264
  handlers = case source
265
- when :agent then agent_handlers
265
+ when :request then request_handlers
266
266
  when :tribunal then tribunal_handlers
267
267
  when :pipeline then pipeline_handlers
268
268
  else []
@@ -50,5 +50,5 @@ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
50
50
  | `InvalidApiKeyError` | no | Missing or invalid API key |
51
51
  | `SafetyBlockedError` | no | Content policy violation |
52
52
 
53
- Retryable errors cause the agent to move to the next model in the chain.
53
+ Retryable errors cause the request to move to the next model in the chain.
54
54
  Non-retryable errors abort the chain immediately and are re-raised.
@@ -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
@@ -20,7 +20,7 @@ module ActiveHarness
20
20
  # POST {azure_api_base}/openai/deployments/{deployment}/chat/completions
21
21
  # ?api-version={azure_api_version}
22
22
  #
23
- # Example agent config:
23
+ # Example request config:
24
24
  # model do
25
25
  # use provider: :azure, model: "my-gpt4o-deployment", temperature: 0.7
26
26
  # end
@@ -4,12 +4,12 @@ module ActiveHarness
4
4
  #
5
5
  # Bedrock requires AWS Signature V4 request signing, which is non-trivial
6
6
  # to implement and carries AWS SDK dependencies. This stub raises a clear
7
- # error so that the agent falls through to the next model in its fallback chain.
7
+ # error so that the request falls through to the next model in its fallback chain.
8
8
  #
9
9
  # To use Bedrock in production, please look for a dedicated gem, for example:
10
10
  # gem "active_harness-bedrock" (not yet released — contributions welcome)
11
11
  #
12
- # Example agent config (will fall through to the next fallback):
12
+ # Example request config (will fall through to the next fallback):
13
13
  # model do
14
14
  # use provider: :bedrock, model: "anthropic.claude-3-5-sonnet-20241022-v2:0"
15
15
  # fallback provider: :anthropic, model: "claude-3-5-sonnet-20241022"
@@ -14,7 +14,7 @@ module ActiveHarness
14
14
  # config.custom["SecondProvider"]["api_key"] = ENV["SECOND_API_KEY"]
15
15
  # end
16
16
  #
17
- # Use in an agent:
17
+ # Use in a request:
18
18
  #
19
19
  # model do
20
20
  # use provider: :custom, name: "MyLocal", model: "llama3.2"
@@ -6,6 +6,8 @@ module ActiveHarness
6
6
  class OpenRouter < Base
7
7
  # @param model [String] e.g. "openai/gpt-5-image-mini", "google/gemini-2.5-flash-image"
8
8
  # @param prompt [String] image description
9
+ # Image-only models use OpenRouter's dedicated Images API. Models that
10
+ # also output text retain the legacy chat-completions request path.
9
11
  # @param size [String] ignored by OpenRouter (passed through for future support)
10
12
  def call(model:, prompt:, size: nil, quality: nil, **_)
11
13
  headers = {
@@ -15,6 +17,22 @@ module ActiveHarness
15
17
  referer = config.openrouter_http_referer.to_s
16
18
  headers["HTTP-Referer"] = referer unless referer.empty?
17
19
 
20
+ return call_images_api(model, prompt, size, quality, headers) if image_only_model?(model)
21
+
22
+ call_chat_api_with_fallback(model, prompt, size, quality, headers)
23
+ end
24
+
25
+ private
26
+
27
+ def call_chat_api_with_fallback(model, prompt, size, quality, headers)
28
+ call_chat_api(model, prompt, size, quality, headers)
29
+ rescue Errors::InvalidRequestError => error
30
+ raise unless unsupported_output_modalities?(error)
31
+
32
+ call_images_api(model, prompt, size, quality, headers)
33
+ end
34
+
35
+ def call_chat_api(model, prompt, size, quality, headers)
18
36
  messages = [{ role: "user", content: prompt }]
19
37
  body = { model: model, messages: messages, modalities: ["image", "text"] }
20
38
  body[:size] = size if size
@@ -30,7 +48,25 @@ module ActiveHarness
30
48
  { content: content, provider: :openrouter, model: model, usage: extract_usage_openai(data) }
31
49
  end
32
50
 
33
- private
51
+ def call_images_api(model, prompt, size, quality, headers)
52
+ body = { model: model, prompt: prompt }
53
+ body[:size] = size if size
54
+ body[:quality] = quality if quality
55
+ body[:output_format] = "png"
56
+
57
+ raw = post_json(URI(config.openrouter_images_api_url), headers: headers, body: body, timeout: 120)
58
+ data = parse!(raw)
59
+ handle_error!(data)
60
+
61
+ content = extract_images_api_image(data)
62
+ raise Errors::ProviderError, "No image data in response: #{data.keys}" unless content
63
+
64
+ { content: content, provider: :openrouter, model: model, usage: extract_usage_openai(data) }
65
+ end
66
+
67
+ def extract_images_api_image(data)
68
+ data.dig("data", 0, "b64_json")
69
+ end
34
70
 
35
71
  def extract_image(data)
36
72
  images = data.dig("choices", 0, "message", "images")
@@ -39,6 +75,18 @@ module ActiveHarness
39
75
  images.first&.dig("image_url", "url")
40
76
  end
41
77
 
78
+ def image_only_model?(model)
79
+ info = Pricing::OpenRouter.find(model)
80
+ modalities = Array(info&.output_modalities).map(&:to_s)
81
+ modalities.include?("image") && !modalities.include?("text")
82
+ rescue StandardError
83
+ false
84
+ end
85
+
86
+ def unsupported_output_modalities?(error)
87
+ error.message.include?("requested output modalities")
88
+ end
89
+
42
90
  def api_key
43
91
  key = config.openrouter_api_key.to_s
44
92
  raise Errors::InvalidApiKeyError, "openrouter_api_key is not configured" if key.empty?
@@ -4,7 +4,7 @@ module ActiveHarness
4
4
  #
5
5
  # Vertex AI requires Google Cloud OAuth2 authentication via Service Account
6
6
  # credentials (googleauth gem) or Application Default Credentials.
7
- # This stub raises a clear error so that the agent falls through to the
7
+ # This stub raises a clear error so that the request falls through to the
8
8
  # next model in its fallback chain.
9
9
  #
10
10
  # To use Vertex AI in production, please look for a dedicated gem, for example:
@@ -13,7 +13,7 @@ module ActiveHarness
13
13
  # For most use cases, consider using the built-in :gemini provider instead:
14
14
  # it accesses Google's Gemini models via a simple API key (no OAuth needed).
15
15
  #
16
- # Example agent config (will fall through to the next fallback):
16
+ # Example request config (will fall through to the next fallback):
17
17
  # model do
18
18
  # use provider: :vertexai, model: "gemini-2.0-flash"
19
19
  # fallback provider: :gemini, model: "gemini-2.0-flash"
@@ -1,6 +1,6 @@
1
1
  module ActiveHarness
2
2
  class Railtie < Rails::Railtie
3
- APP_AI_DIRS = %w[agents prompts tribunals pipelines memory].freeze
3
+ APP_AI_DIRS = %w[requests prompts tribunals pipelines memory].freeze
4
4
 
5
5
  initializer "active_harness.autoload_paths", before: :set_autoload_paths do |app|
6
6
  APP_AI_DIRS.each do |dir|
@@ -1,5 +1,5 @@
1
1
  module ActiveHarness
2
- class Agent
2
+ class Request
3
3
  # Providers that have a dedicated pricing source beyond ModelsDev.
4
4
  # Consulted in tier-2 before the general ModelsDev fallback.
5
5
  # Add entries here when a new provider-specific source is available.