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.
@@ -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 [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
- # @return [String] full accumulated content
16
- def post(url, headers:, body:, timeout: 60, on_token:)
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 = nil
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
- token = parsed.dig("choices", 0, "delta", "content")
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 ||= parsed["usage"] if parsed.key?("usage")
50
+ usage = usage.merge(info[:usage]) if info[:usage]
47
51
  end
48
52
  end
49
53
  end
50
54
 
51
- { content: content, raw_usage: usage }
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: model,
16
- max_tokens: DEFAULT_MAX_TOKENS,
15
+ model: model,
16
+ max_tokens: DEFAULT_MAX_TOKENS,
17
17
  temperature: temperature,
18
- messages: chat_messages
18
+ messages: chat_messages
19
19
  }
20
20
  body[:system] = system_msg if system_msg
21
21
 
22
- raw = post_json(URI(config.anthropic_api_url),
23
- headers: {
24
- "Content-Type" => "application/json",
25
- "x-api-key" => api_key,
26
- "anthropic-version" => ANTHROPIC_VERSION
27
- },
28
- body: body
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
- # Adds stream: true and stream_options to body, calls StreamingClient,
50
- # and returns the same { content:, provider:, model:, usage: } shape
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 = body.merge(stream: true, stream_options: { include_usage: true })
54
- result = post_json_stream(URI(url), headers: headers, body: body, on_token: stream)
55
- {
56
- content: result[:content],
57
- provider: provider,
58
- model: model,
59
- usage: extract_usage_openai({ "usage" => result[:raw_usage] })
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)
@@ -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.10
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-24 00:00:00.000000000 Z
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