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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +2 -2
- data/CHANGELOG.md +343 -0
- data/README.md +75 -7
- data/lib/agent_harness/openai_compatible_transport.rb +14 -3
- data/lib/agent_harness/providers/anthropic.rb +48 -0
- data/lib/agent_harness/providers/base.rb +57 -0
- data/lib/agent_harness/providers/codex.rb +20 -0
- data/lib/agent_harness/providers/kilocode.rb +114 -11
- data/lib/agent_harness/providers/opencode.rb +2 -2
- data/lib/agent_harness/providers/quota_checkers/open_router.rb +154 -0
- data/lib/agent_harness/providers/quota_checkers.rb +3 -0
- data/lib/agent_harness/quota_status.rb +175 -0
- data/lib/agent_harness/text_transport.rb +14 -3
- data/lib/agent_harness/token_usage_tracker.rb +196 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +11 -0
- metadata +6 -2
|
@@ -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
|
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.
|
|
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.
|
|
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: []
|