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.
- checksums.yaml +4 -4
- data/lib/active_harness/configuration.rb +3 -1
- data/lib/active_harness/core/errors.rb +2 -2
- data/lib/active_harness/core/hooks.rb +2 -2
- data/lib/active_harness/memory.rb +11 -11
- data/lib/active_harness/pipeline/README.md +15 -15
- data/lib/active_harness/pipeline/step.rb +1 -1
- data/lib/active_harness/pipeline.rb +18 -18
- data/lib/active_harness/providers/PROVIDER_CONTRACT.md +1 -1
- data/lib/active_harness/providers/audio/openai.rb +127 -0
- data/lib/active_harness/providers/azure.rb +1 -1
- data/lib/active_harness/providers/bedrock.rb +2 -2
- data/lib/active_harness/providers/custom.rb +1 -1
- data/lib/active_harness/providers/images/openrouter.rb +49 -1
- data/lib/active_harness/providers/vertexai.rb +2 -2
- data/lib/active_harness/railtie.rb +1 -1
- data/lib/active_harness/{agent → request}/cost.rb +1 -1
- data/lib/active_harness/{agent → request}/custom_llm_backend.rb +4 -4
- data/lib/active_harness/{agent → request}/hooks.rb +4 -4
- data/lib/active_harness/{agent → request}/image.rb +7 -7
- data/lib/active_harness/{agent → request}/models.rb +15 -15
- data/lib/active_harness/{agent → request}/output_parser.rb +1 -1
- data/lib/active_harness/{agent → request}/prompt.rb +7 -7
- data/lib/active_harness/{agent → request}/providers.rb +2 -1
- data/lib/active_harness/{agent → request}/transcription.rb +7 -7
- data/lib/active_harness/{agent.rb → request.rb} +19 -19
- data/lib/active_harness/result.rb +3 -3
- data/lib/active_harness/tribunal/dsl.rb +7 -7
- data/lib/active_harness/tribunal/hooks.rb +9 -9
- data/lib/active_harness/tribunal.rb +62 -62
- data/lib/active_harness.rb +3 -2
- data/lib/generators/active_harness/install/install_generator.rb +1 -1
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +3 -3
- data/lib/generators/active_harness/install/templates/initializers/active_harness.rb +2 -2
- data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +2 -2
- data/lib/generators/active_harness/install/templates/{agents/support_guard_agent.rb → requests/support_guard_request.rb} +1 -1
- data/lib/generators/active_harness/install/templates/{agents/support_agent.rb → requests/support_request.rb} +1 -1
- data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +3 -3
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +1 -1
- data/lib/generators/active_harness/request/request_generator.rb +16 -0
- data/lib/generators/active_harness/{agent/templates/agent.rb.tt → request/templates/request.rb.tt} +1 -1
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +1 -1
- metadata +18 -17
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 779258acc7a5947e923ac8f5ad262f653e96ad0509585f8a309e362bc0ee3703
|
|
4
|
+
data.tar.gz: 1364d064abfe80ccdd5fdadd7bfb6d55396ec2ceaf7680d0bdd1373011cd9923
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
16
|
-
# anything —
|
|
17
|
-
# recording turns for a bare
|
|
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
|
|
23
|
+
# individual requests define.
|
|
24
24
|
#
|
|
25
25
|
# memory = ActiveHarness::Memory.new(session_id: "u42", depth: 8)
|
|
26
|
-
#
|
|
27
|
-
# # => nothing is saved unless
|
|
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-
|
|
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
|
|
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
|
|
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[:
|
|
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
|
|
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,
|
|
10
|
+
step :translate, TranslationRequest
|
|
11
11
|
|
|
12
12
|
step :injection_guard do
|
|
13
|
-
use
|
|
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, <
|
|
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
|
-
**
|
|
35
|
+
**Request step** — runs the request, takes `result.output` as the new payload:
|
|
36
36
|
|
|
37
37
|
```ruby
|
|
38
|
-
step :translate,
|
|
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
|
-
|
|
|
58
|
-
|
|
|
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
|
|
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
|
-
|
|
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,
|
|
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: `
|
|
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;
|
|
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
|
|
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
|
-
|
|
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.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
module ActiveHarness
|
|
2
|
-
# Sequential pipeline that chains
|
|
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
|
|
9
|
+
# use InjectionGuardRequest
|
|
10
10
|
# stop_if ->(result) { result.processed["detected"] == true }
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
13
|
-
# step :translate,
|
|
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 (
|
|
42
|
-
# step :translate,
|
|
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
|
|
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
|
|
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
|
-
#
|
|
72
|
-
# Rails.logger.info "[
|
|
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
|
|
83
|
-
(pipeline_config[:streams][:
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 :
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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[
|
|
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|
|