agent-harness 0.30.0 → 0.33.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.
@@ -144,7 +144,7 @@ module AgentHarness
144
144
 
145
145
  @logger&.debug("[AgentHarness::TextTransport] POST #{uri} model=#{body[:model]} stream=true")
146
146
 
147
- accumulated = {content: +"", model: nil, usage: nil, tool_calls: []}
147
+ accumulated = {content: +"", model: nil, usage: nil, tool_calls: [], headers: {}}
148
148
 
149
149
  http.request(request) do |http_response|
150
150
  status_code = http_response.code.to_i
@@ -153,6 +153,7 @@ module AgentHarness
153
153
  handle_error_response_raw(response_body, status_code)
154
154
  end
155
155
 
156
+ accumulated[:headers] = extract_headers(http_response)
156
157
  parse_sse_stream(http_response, accumulated, &on_chunk)
157
158
  end
158
159
 
@@ -280,7 +281,7 @@ module AgentHarness
280
281
  tokens = extract_tokens(body)
281
282
  tool_calls = extract_tool_calls(body)
282
283
 
283
- metadata = {transport: :http}
284
+ metadata = {transport: :http, headers: extract_headers(http_response)}
284
285
  metadata[:tool_calls] = tool_calls if tool_calls
285
286
 
286
287
  Response.new(
@@ -301,7 +302,7 @@ module AgentHarness
301
302
 
302
303
  def build_streaming_response(accumulated, duration:, model:)
303
304
  tool_calls = accumulated[:tool_calls].compact
304
- metadata = {transport: :http, stream: true}
305
+ metadata = {transport: :http, stream: true, headers: accumulated[:headers] || {}}
305
306
  metadata[:tool_calls] = tool_calls unless tool_calls.empty?
306
307
 
307
308
  Response.new(
@@ -389,6 +390,16 @@ module AgentHarness
389
390
  end
390
391
  end
391
392
 
393
+ def extract_headers(http_response)
394
+ return {} unless http_response.respond_to?(:each_header)
395
+
396
+ {}.tap do |headers|
397
+ http_response.each_header do |name, value|
398
+ headers[name] = value
399
+ end
400
+ end
401
+ end
402
+
392
403
  def build_chat_chunk_callback(on_chunk, on_chat_chunk, observer)
393
404
  proc do |chunk|
394
405
  on_chunk&.call(chunk)
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AgentHarness
6
+ # Fallback quota tracker for providers that do not expose a public quota API.
7
+ #
8
+ # When {Providers::Base#check_quota} returns a {QuotaStatus} with
9
+ # +available: false+ (the default for providers like Aider or Kilocode that
10
+ # route through opaque upstreams), the orchestration layer falls back to
11
+ # {TokenUsageTracker}. The tracker records the same per-request token usage
12
+ # surfaced by {Providers::Base#track_tokens}, then derives an estimated
13
+ # {QuotaStatus} by subtracting usage from a caller-configured billing
14
+ # period limit.
15
+ #
16
+ # The tracker holds no connection to the provider and never makes an HTTP
17
+ # request; it is purely an in-memory aggregate that mirrors what
18
+ # {TokenTracker} records but with the additional concept of a billing-period
19
+ # limit per provider.
20
+ #
21
+ # @example Configure a daily token limit and record usage
22
+ # tracker = AgentHarness::TokenUsageTracker.new
23
+ # tracker.set_quota(provider: :aider, limit: 2_000_000, reset_at: Time.utc(2026, 8, 1), unit: :tokens)
24
+ # tracker.record(provider: :aider, model: "gpt-4o", input_tokens: 500, output_tokens: 100)
25
+ # tracker.estimated_usage(provider: :aider)
26
+ # # => #<AgentHarness::QuotaStatus available=true remaining=1999400 limit=2000000 unit=:tokens>
27
+ class TokenUsageTracker
28
+ UsageEvent = Struct.new(:provider, :model, :input_tokens, :output_tokens, :total_tokens, :timestamp, :request_id)
29
+
30
+ QuotaConfig = Struct.new(:limit, :reset_at, :unit) do
31
+ def configured?
32
+ !limit.nil?
33
+ end
34
+ end
35
+
36
+ def initialize
37
+ @events = []
38
+ @quota_configs = Hash.new { |hash, key| hash[key] = QuotaConfig.new }
39
+ @callbacks = []
40
+ @mutex = Mutex.new
41
+ end
42
+
43
+ # Configure the billing-period quota for a provider.
44
+ #
45
+ # The orchestration layer uses this to seed tracker state from Paid's
46
+ # provider config before any usage is recorded.
47
+ #
48
+ # @param provider [Symbol, String] provider name
49
+ # @param limit [Integer, Float, nil] total quota for the period; pass nil
50
+ # to mark the provider as untracked
51
+ # @param reset_at [Time, nil] when the period resets
52
+ # @param unit [Symbol] quota unit (:tokens, :requests, :credits, :cost_cents)
53
+ # @return [void]
54
+ def set_quota(provider:, limit:, reset_at:, unit:)
55
+ provider_key = normalize_provider(provider)
56
+ @mutex.synchronize do
57
+ @quota_configs[provider_key] = QuotaConfig.new(limit: limit, reset_at: reset_at, unit: normalize_unit(unit))
58
+ end
59
+ end
60
+
61
+ # Record token usage for a provider.
62
+ #
63
+ # Mirrors the {TokenTracker#record} signature so the tracker can be wired
64
+ # into the same +track_tokens+ hook on {Providers::Base}.
65
+ #
66
+ # @param provider [Symbol, String] provider name
67
+ # @param model [String, nil] model identifier
68
+ # @param input_tokens [Integer] input tokens used
69
+ # @param output_tokens [Integer] output tokens used
70
+ # @param total_tokens [Integer, nil] total tokens (calculated if nil)
71
+ # @param request_id [String, nil] unique request ID (generated if nil)
72
+ # @return [UsageEvent]
73
+ def record(provider:, model: nil, input_tokens: 0, output_tokens: 0, total_tokens: nil, request_id: nil)
74
+ total = total_tokens || (input_tokens + output_tokens)
75
+ event = UsageEvent.new(
76
+ provider: normalize_provider(provider),
77
+ model: model,
78
+ input_tokens: input_tokens.to_i,
79
+ output_tokens: output_tokens.to_i,
80
+ total_tokens: total.to_i,
81
+ timestamp: Time.now.utc,
82
+ request_id: request_id || SecureRandom.uuid
83
+ )
84
+
85
+ @mutex.synchronize { @events << event }
86
+ notify_callbacks(event)
87
+ event
88
+ end
89
+
90
+ # Return an estimated {QuotaStatus} for the given provider.
91
+ #
92
+ # Returns a {QuotaStatus} with +available: true+ as long as the caller has
93
+ # configured a quota for the provider (via {#set_quota}). The +remaining+
94
+ # value is the configured limit minus recorded usage since +since:+ (or
95
+ # every recorded event when +since:+ is omitted — callers are responsible
96
+ # for clearing events with {#clear!} when a billing period rolls over).
97
+ #
98
+ # @param provider [Symbol, String] provider name
99
+ # @param since [Time, nil] cutoff for usage events
100
+ # @return [AgentHarness::QuotaStatus]
101
+ def estimated_usage(provider:, since: nil)
102
+ provider_key = normalize_provider(provider)
103
+ config = @mutex.synchronize { @quota_configs[provider_key] }
104
+ return QuotaStatus.unavailable unless config&.configured?
105
+
106
+ used = usage_total(provider_key, since:)
107
+ remaining = config.limit.nil? ? nil : (config.limit - used)
108
+
109
+ QuotaStatus.new(
110
+ available: true,
111
+ remaining: remaining,
112
+ limit: config.limit,
113
+ reset_at: config.reset_at,
114
+ unit: config.unit
115
+ )
116
+ end
117
+
118
+ # Register a callback invoked whenever usage is recorded.
119
+ #
120
+ # @yield [UsageEvent]
121
+ # @return [void]
122
+ def on_usage_recorded(&block)
123
+ @callbacks << block
124
+ end
125
+
126
+ # Drop all recorded events for a provider. Useful when a billing period
127
+ # rolls over or in tests.
128
+ #
129
+ # @param provider [Symbol, String, nil] provider name; clears everything when nil
130
+ # @return [void]
131
+ def clear!(provider: nil)
132
+ @mutex.synchronize do
133
+ if provider.nil?
134
+ @events.clear
135
+ else
136
+ provider_key = normalize_provider(provider)
137
+ @events.reject! { |event| event.provider == provider_key }
138
+ end
139
+ end
140
+ end
141
+
142
+ # Total recorded token usage for a provider since an optional cutoff.
143
+ #
144
+ # @param provider [Symbol, String] provider name
145
+ # @param since [Time, nil] cutoff
146
+ # @return [Integer]
147
+ def usage_total(provider, since: nil)
148
+ provider_key = normalize_provider(provider)
149
+ events = filtered_events(provider_key, since:)
150
+ events.sum(&:total_tokens)
151
+ end
152
+
153
+ # Number of usage events recorded for a provider since an optional cutoff.
154
+ #
155
+ # @param provider [Symbol, String] provider name
156
+ # @param since [Time, nil] cutoff
157
+ # @return [Integer]
158
+ def event_count(provider = nil, since: nil)
159
+ @mutex.synchronize do
160
+ events = @events.dup
161
+ events = events.select { |event| event.provider == normalize_provider(provider) } if provider
162
+ events = events.select { |event| since.nil? || event.timestamp >= since }
163
+ events.size
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def normalize_provider(provider)
170
+ provider.to_sym
171
+ end
172
+
173
+ def normalize_unit(unit)
174
+ return nil if unit.nil?
175
+
176
+ unit.to_sym
177
+ end
178
+
179
+ def filtered_events(provider_key, since:)
180
+ @mutex.synchronize do
181
+ events = @events.select { |event| event.provider == provider_key }
182
+ return events if since.nil?
183
+
184
+ events.select { |event| event.timestamp >= since }
185
+ end
186
+ end
187
+
188
+ def notify_callbacks(event)
189
+ @callbacks.each do |callback|
190
+ callback.call(event)
191
+ rescue => e
192
+ AgentHarness.logger&.error("[AgentHarness::TokenUsageTracker] Callback error: #{e.message}")
193
+ end
194
+ end
195
+ end
196
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.30.0"
4
+ VERSION = "0.33.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -47,6 +47,7 @@ module AgentHarness
47
47
  @configuration = nil
48
48
  @conductor = nil
49
49
  @token_tracker = nil
50
+ @token_usage_tracker = nil
50
51
  @dependency_updater = nil
51
52
  Skills.reset! if defined?(Skills)
52
53
  end
@@ -63,6 +64,13 @@ module AgentHarness
63
64
  @token_tracker ||= TokenTracker.new
64
65
  end
65
66
 
67
+ # Returns the global token usage tracker used as a fallback when providers
68
+ # do not expose a proactive quota API.
69
+ # @return [TokenUsageTracker] the token usage tracker instance
70
+ def token_usage_tracker
71
+ @token_usage_tracker ||= TokenUsageTracker.new
72
+ end
73
+
66
74
  # Returns the global conductor for orchestrated requests
67
75
  # @return [Orchestration::Conductor] the conductor instance
68
76
  def conductor
@@ -449,10 +457,12 @@ require_relative "agent_harness/command_executor"
449
457
  require_relative "agent_harness/docker_command_executor"
450
458
  require_relative "agent_harness/response"
451
459
  require_relative "agent_harness/token_tracker"
460
+ require_relative "agent_harness/token_usage_tracker"
452
461
  require_relative "agent_harness/error_taxonomy"
453
462
  require_relative "agent_harness/text_transport"
454
463
  require_relative "agent_harness/openai_compatible_transport"
455
464
  require_relative "agent_harness/conversation"
465
+ require_relative "agent_harness/quota_status"
456
466
  require_relative "agent_harness/authentication"
457
467
  require_relative "agent_harness/provider_health_check"
458
468
  require_relative "agent_harness/release_registry"
@@ -466,6 +476,7 @@ require_relative "agent_harness/providers/token_usage_parsing"
466
476
  require_relative "agent_harness/providers/rate_limit_reset_parsing"
467
477
  require_relative "agent_harness/providers/base"
468
478
  require_relative "agent_harness/providers/mcp_config_file_support"
479
+ require_relative "agent_harness/providers/quota_checkers"
469
480
  require_relative "agent_harness/providers/anthropic"
470
481
  require_relative "agent_harness/providers/aider"
471
482
  require_relative "agent_harness/providers/codex"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -137,9 +137,12 @@ files:
137
137
  - lib/agent_harness/providers/omp.rb
138
138
  - lib/agent_harness/providers/opencode.rb
139
139
  - lib/agent_harness/providers/pi.rb
140
+ - lib/agent_harness/providers/quota_checkers.rb
141
+ - lib/agent_harness/providers/quota_checkers/open_router.rb
140
142
  - lib/agent_harness/providers/rate_limit_reset_parsing.rb
141
143
  - lib/agent_harness/providers/registry.rb
142
144
  - lib/agent_harness/providers/token_usage_parsing.rb
145
+ - lib/agent_harness/quota_status.rb
143
146
  - lib/agent_harness/release_registry.rb
144
147
  - lib/agent_harness/response.rb
145
148
  - lib/agent_harness/skill.rb
@@ -149,6 +152,7 @@ files:
149
152
  - lib/agent_harness/sub_agent_translator.rb
150
153
  - lib/agent_harness/text_transport.rb
151
154
  - lib/agent_harness/token_tracker.rb
155
+ - lib/agent_harness/token_usage_tracker.rb
152
156
  - lib/agent_harness/version.rb
153
157
  - release-please-config.json
154
158
  homepage: https://github.com/viamin/agent-harness
@@ -173,7 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
173
177
  - !ruby/object:Gem::Version
174
178
  version: '0'
175
179
  requirements: []
176
- rubygems_version: 4.0.10
180
+ rubygems_version: 4.0.16
177
181
  specification_version: 4
178
182
  summary: Unified interface for CLI-based AI coding agents
179
183
  test_files: []