active_harness 0.2.10 → 0.2.12
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/costs.rb +253 -0
- data/lib/active_harness/data/models.json +61458 -0
- data/lib/active_harness/http/streaming_client.rb +25 -15
- data/lib/active_harness/providers/anthropic.rb +37 -12
- data/lib/active_harness/providers/base.rb +24 -13
- data/lib/active_harness.rb +1 -0
- metadata +4 -2
|
@@ -7,13 +7,16 @@ module ActiveHarness
|
|
|
7
7
|
# Calls +on_token+ for each content token as it arrives via SSE.
|
|
8
8
|
# Accumulates and returns the full content string when the stream ends.
|
|
9
9
|
class StreamingClient
|
|
10
|
-
# @param url
|
|
11
|
-
# @param headers
|
|
12
|
-
# @param body
|
|
13
|
-
# @param timeout
|
|
14
|
-
# @param on_token
|
|
15
|
-
# @
|
|
16
|
-
|
|
10
|
+
# @param url [URI]
|
|
11
|
+
# @param headers [Hash{String => String}]
|
|
12
|
+
# @param body [String] JSON-serialized body
|
|
13
|
+
# @param timeout [Integer] seconds (open + read)
|
|
14
|
+
# @param on_token [Proc] called with each partial token string
|
|
15
|
+
# @param parse_chunk [Proc, nil] receives each parsed SSE JSON hash;
|
|
16
|
+
# must return { token: String|nil, usage: Hash|nil }.
|
|
17
|
+
# Defaults to OpenAI-compatible format.
|
|
18
|
+
# @return [Hash] { content: String, usage: Hash|nil }
|
|
19
|
+
def post(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
|
|
17
20
|
http = Net::HTTP.new(url.host, url.port)
|
|
18
21
|
http.use_ssl = true
|
|
19
22
|
http.open_timeout = timeout
|
|
@@ -25,7 +28,7 @@ module ActiveHarness
|
|
|
25
28
|
|
|
26
29
|
buffer = ""
|
|
27
30
|
content = ""
|
|
28
|
-
usage =
|
|
31
|
+
usage = {}
|
|
29
32
|
|
|
30
33
|
http.request(req) do |response|
|
|
31
34
|
response.read_body do |chunk|
|
|
@@ -37,26 +40,33 @@ module ActiveHarness
|
|
|
37
40
|
data = line.delete_prefix("data: ")
|
|
38
41
|
next if data == "[DONE]"
|
|
39
42
|
|
|
40
|
-
parsed = JSON.parse(data)
|
|
41
|
-
|
|
43
|
+
parsed = JSON.parse(data) rescue next
|
|
44
|
+
info = parse_chunk ? parse_chunk.call(parsed) : default_chunk(parsed)
|
|
45
|
+
token = info[:token]
|
|
42
46
|
if token && !token.empty?
|
|
43
47
|
on_token.call(token)
|
|
44
48
|
content += token
|
|
45
49
|
end
|
|
46
|
-
usage
|
|
50
|
+
usage = usage.merge(info[:usage]) if info[:usage]
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
{ content: content,
|
|
55
|
+
{ content: content, usage: usage.empty? ? nil : usage }
|
|
52
56
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
53
57
|
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
54
|
-
rescue JSON::ParserError
|
|
55
|
-
# ignore malformed SSE chunks
|
|
56
|
-
content
|
|
57
58
|
rescue => e
|
|
58
59
|
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
59
60
|
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def default_chunk(parsed)
|
|
65
|
+
token = parsed.dig("choices", 0, "delta", "content")
|
|
66
|
+
raw_u = parsed["usage"]
|
|
67
|
+
usage = raw_u ? { input_tokens: raw_u["prompt_tokens"].to_i, output_tokens: raw_u["completion_tokens"].to_i } : nil
|
|
68
|
+
{ token: token, usage: usage }
|
|
69
|
+
end
|
|
60
70
|
end
|
|
61
71
|
end
|
|
62
72
|
end
|
|
@@ -8,25 +8,26 @@ module ActiveHarness
|
|
|
8
8
|
ANTHROPIC_VERSION = "2023-06-01"
|
|
9
9
|
DEFAULT_MAX_TOKENS = 1024
|
|
10
10
|
|
|
11
|
-
def call(model:, messages:, temperature: 0.7)
|
|
11
|
+
def call(model:, messages:, temperature: 0.7, stream: nil)
|
|
12
12
|
system_msg, chat_messages = extract_system(messages)
|
|
13
13
|
|
|
14
14
|
body = {
|
|
15
|
-
model:
|
|
16
|
-
max_tokens:
|
|
15
|
+
model: model,
|
|
16
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
17
17
|
temperature: temperature,
|
|
18
|
-
messages:
|
|
18
|
+
messages: chat_messages
|
|
19
19
|
}
|
|
20
20
|
body[:system] = system_msg if system_msg
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
headers = {
|
|
23
|
+
"Content-Type" => "application/json",
|
|
24
|
+
"x-api-key" => api_key,
|
|
25
|
+
"anthropic-version" => ANTHROPIC_VERSION
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return call_streaming(url: config.anthropic_api_url, headers: headers, body: body, stream: stream, provider: :anthropic, model: model) if stream
|
|
29
|
+
|
|
30
|
+
raw = post_json(URI(config.anthropic_api_url), headers: headers, body: body)
|
|
30
31
|
data = parse!(raw)
|
|
31
32
|
handle_error!(data)
|
|
32
33
|
|
|
@@ -77,6 +78,30 @@ module ActiveHarness
|
|
|
77
78
|
raise Errors::InvalidRequestError.new(msg, error_code: type, metadata: metadata)
|
|
78
79
|
end
|
|
79
80
|
end
|
|
81
|
+
|
|
82
|
+
# Anthropic streaming uses plain stream: true — no stream_options.
|
|
83
|
+
def prepare_streaming_body(body)
|
|
84
|
+
body.merge(stream: true)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Anthropic SSE events:
|
|
88
|
+
# message_start → input token count
|
|
89
|
+
# content_block_delta → text token
|
|
90
|
+
# message_delta → output token count
|
|
91
|
+
def build_streaming_chunk(parsed)
|
|
92
|
+
token = if parsed["type"] == "content_block_delta" && parsed.dig("delta", "type") == "text_delta"
|
|
93
|
+
parsed.dig("delta", "text")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
usage = case parsed["type"]
|
|
97
|
+
when "message_start"
|
|
98
|
+
{ input_tokens: parsed.dig("message", "usage", "input_tokens").to_i }
|
|
99
|
+
when "message_delta"
|
|
100
|
+
{ output_tokens: parsed.dig("usage", "output_tokens").to_i }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{ token: token, usage: usage }
|
|
104
|
+
end
|
|
80
105
|
end
|
|
81
106
|
end
|
|
82
107
|
end
|
|
@@ -16,8 +16,8 @@ module ActiveHarness
|
|
|
16
16
|
HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def post_json_stream(url, headers:, body:, timeout: 60, on_token:)
|
|
20
|
-
STREAMING_HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout, on_token: on_token)
|
|
19
|
+
def post_json_stream(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
|
|
20
|
+
STREAMING_HTTP.post(url, headers: headers, body: body.to_json, timeout: timeout, on_token: on_token, parse_chunk: parse_chunk)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Normalize OpenAI-compatible usage object to a consistent hash.
|
|
@@ -46,18 +46,29 @@ module ActiveHarness
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# Streaming call for OpenAI-compatible providers.
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# as non-streaming calls so callers need no special handling.
|
|
49
|
+
# Subclasses may override +prepare_streaming_body+ and +build_streaming_chunk+
|
|
50
|
+
# to support non-OpenAI SSE formats (e.g. Anthropic).
|
|
52
51
|
def call_streaming(url:, headers:, body:, stream:, provider:, model:)
|
|
53
|
-
body
|
|
54
|
-
result = post_json_stream(URI(url), headers: headers, body: body, on_token: stream)
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
body = prepare_streaming_body(body)
|
|
53
|
+
result = post_json_stream(URI(url), headers: headers, body: body, on_token: stream, parse_chunk: method(:build_streaming_chunk))
|
|
54
|
+
u = result[:usage] || {}
|
|
55
|
+
usage = u.any? ? { input_tokens: u[:input_tokens].to_i, output_tokens: u[:output_tokens].to_i, total_tokens: u[:input_tokens].to_i + u[:output_tokens].to_i } : nil
|
|
56
|
+
{ content: result[:content], provider: provider, model: model, usage: usage }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Override in subclass to change streaming request body options.
|
|
60
|
+
def prepare_streaming_body(body)
|
|
61
|
+
body.merge(stream: true, stream_options: { include_usage: true })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Override in subclass to parse provider-specific SSE chunks.
|
|
65
|
+
# Must return { token: String|nil, usage: Hash|nil } where usage keys
|
|
66
|
+
# are :input_tokens and :output_tokens.
|
|
67
|
+
def build_streaming_chunk(parsed)
|
|
68
|
+
token = parsed.dig("choices", 0, "delta", "content")
|
|
69
|
+
raw_u = parsed["usage"]
|
|
70
|
+
usage = raw_u ? { input_tokens: raw_u["prompt_tokens"].to_i, output_tokens: raw_u["completion_tokens"].to_i } : nil
|
|
71
|
+
{ token: token, usage: usage }
|
|
61
72
|
end
|
|
62
73
|
|
|
63
74
|
def parse!(raw)
|
data/lib/active_harness.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "active_harness/providers/azure"
|
|
|
20
20
|
require_relative "active_harness/providers/bedrock"
|
|
21
21
|
require_relative "active_harness/providers/vertexai"
|
|
22
22
|
require_relative "active_harness/providers/custom"
|
|
23
|
+
require_relative "active_harness/costs"
|
|
23
24
|
require_relative "active_harness/memory"
|
|
24
25
|
require_relative "active_harness/agent"
|
|
25
26
|
require_relative "active_harness/tribunal"
|
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.
|
|
4
|
+
version: 0.2.12
|
|
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-05-
|
|
11
|
+
date: 2026-05-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -41,6 +41,8 @@ files:
|
|
|
41
41
|
- lib/active_harness/agent/ruby_llm_backend.rb
|
|
42
42
|
- lib/active_harness/configuration.rb
|
|
43
43
|
- lib/active_harness/core/errors.rb
|
|
44
|
+
- lib/active_harness/costs.rb
|
|
45
|
+
- lib/active_harness/data/models.json
|
|
44
46
|
- lib/active_harness/http/client.rb
|
|
45
47
|
- lib/active_harness/http/retry_policy.rb
|
|
46
48
|
- lib/active_harness/http/streaming_client.rb
|