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
|
@@ -17,6 +17,14 @@ module AgentHarness
|
|
|
17
17
|
include RateLimitResetParsing
|
|
18
18
|
include McpConfigFileSupport
|
|
19
19
|
|
|
20
|
+
# Anthropic rate-limit response headers exposing quota/rate information.
|
|
21
|
+
# These are surfaced on normal /v1/messages responses and parsed by
|
|
22
|
+
# {#update_quota_from_headers} to opportunistically refresh cached quota
|
|
23
|
+
# without making a dedicated API call.
|
|
24
|
+
RATE_LIMIT_HEADER_LIMIT = "anthropic-ratelimit-tokens-limit"
|
|
25
|
+
RATE_LIMIT_HEADER_REMAINING = "anthropic-ratelimit-tokens-remaining"
|
|
26
|
+
RATE_LIMIT_HEADER_RESET = "anthropic-ratelimit-tokens-reset"
|
|
27
|
+
|
|
20
28
|
# Model name pattern for Anthropic Claude models
|
|
21
29
|
MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
|
|
22
30
|
SUPPORTED_CLI_VERSION = "2.1.92"
|
|
@@ -411,6 +419,25 @@ module AgentHarness
|
|
|
411
419
|
|
|
412
420
|
def subscription_unset_vars = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"] + api_key_unset_vars
|
|
413
421
|
|
|
422
|
+
# Opportunistically refresh quota info from Anthropic rate-limit headers
|
|
423
|
+
# observed on a normal /v1/messages response.
|
|
424
|
+
#
|
|
425
|
+
# @param headers [Hash{String=>String}, Net::HTTPHeader] response headers
|
|
426
|
+
# @return [AgentHarness::QuotaStatus, nil]
|
|
427
|
+
def update_quota_from_headers(headers)
|
|
428
|
+
limit_value = header_value(headers, RATE_LIMIT_HEADER_LIMIT)
|
|
429
|
+
remaining_value = header_value(headers, RATE_LIMIT_HEADER_REMAINING)
|
|
430
|
+
return nil unless limit_value || remaining_value
|
|
431
|
+
|
|
432
|
+
QuotaStatus.new(
|
|
433
|
+
available: true,
|
|
434
|
+
remaining: remaining_value&.to_i,
|
|
435
|
+
limit: limit_value&.to_i,
|
|
436
|
+
reset_at: parse_rate_limit_header_reset(headers),
|
|
437
|
+
unit: :tokens
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
|
|
414
441
|
def supports_mcp?
|
|
415
442
|
true
|
|
416
443
|
end
|
|
@@ -678,6 +705,7 @@ module AgentHarness
|
|
|
678
705
|
kwargs[:max_tokens] = max_tokens if max_tokens
|
|
679
706
|
|
|
680
707
|
response = transport.send_message(prompt, **kwargs)
|
|
708
|
+
response = attach_quota_status_from_headers(response)
|
|
681
709
|
|
|
682
710
|
# Apply runtime model override if present
|
|
683
711
|
runtime = options[:provider_runtime]
|
|
@@ -814,6 +842,26 @@ module AgentHarness
|
|
|
814
842
|
def log_debug(action, **context)
|
|
815
843
|
@logger&.debug("[AgentHarness::Anthropic] #{action}: #{context.inspect}")
|
|
816
844
|
end
|
|
845
|
+
|
|
846
|
+
# Read a header value from either a Net::HTTPHeader or a plain Hash.
|
|
847
|
+
def header_value(headers, name)
|
|
848
|
+
return headers[name] || headers[name.to_sym] if headers.respond_to?(:[])
|
|
849
|
+
|
|
850
|
+
nil
|
|
851
|
+
rescue NoMethodError
|
|
852
|
+
nil
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Anthropic's ratelimit reset header is an RFC 3339 timestamp
|
|
856
|
+
# (e.g. "2026-07-21T05:00:00Z"), per the API docs.
|
|
857
|
+
def parse_rate_limit_header_reset(headers)
|
|
858
|
+
raw = header_value(headers, RATE_LIMIT_HEADER_RESET)
|
|
859
|
+
return nil unless raw
|
|
860
|
+
|
|
861
|
+
Time.iso8601(raw.to_s).utc
|
|
862
|
+
rescue ArgumentError
|
|
863
|
+
nil
|
|
864
|
+
end
|
|
817
865
|
end
|
|
818
866
|
end
|
|
819
867
|
end
|
|
@@ -322,6 +322,7 @@ module AgentHarness
|
|
|
322
322
|
&on_chunk
|
|
323
323
|
)
|
|
324
324
|
|
|
325
|
+
response = attach_quota_status_from_headers(response)
|
|
325
326
|
response = apply_extensions_after_response(extension_context, response)
|
|
326
327
|
|
|
327
328
|
track_tokens(response) if response.tokens
|
|
@@ -447,6 +448,39 @@ module AgentHarness
|
|
|
447
448
|
{healthy: true}
|
|
448
449
|
end
|
|
449
450
|
|
|
451
|
+
# Proactively query remaining provider quota for the current billing
|
|
452
|
+
# period without running a full agent.
|
|
453
|
+
#
|
|
454
|
+
# The default implementation reports that quota checking is unavailable
|
|
455
|
+
# so callers can fall back to {AgentHarness::TokenUsageTracker}. Providers
|
|
456
|
+
# that expose a usage/quota API override this to return a populated
|
|
457
|
+
# {QuotaStatus}.
|
|
458
|
+
#
|
|
459
|
+
# The +env+ hash carries the same provider credentials and overrides that
|
|
460
|
+
# {send_message} uses (typically derived from +ProviderRuntime#env+), so
|
|
461
|
+
# the quota check can reuse the same authentication as a normal run.
|
|
462
|
+
#
|
|
463
|
+
# @param env [Hash{String=>String}] request-scoped environment overrides
|
|
464
|
+
# @param timeout [Numeric] time budget in seconds
|
|
465
|
+
# @return [AgentHarness::QuotaStatus]
|
|
466
|
+
def check_quota(env:, timeout: 10)
|
|
467
|
+
QuotaStatus.unavailable
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Opportunistically update cached quota info from rate-limit headers
|
|
471
|
+
# observed during a normal {send_message} flow.
|
|
472
|
+
#
|
|
473
|
+
# Providers that emit +x-ratelimit-*+ style headers should override this
|
|
474
|
+
# to parse them into a {QuotaStatus} and return it. The base
|
|
475
|
+
# implementation is a no-op so providers that do not expose headers (or
|
|
476
|
+
# runs where the information is unavailable) are handled gracefully.
|
|
477
|
+
#
|
|
478
|
+
# @param headers [Hash{String=>String}, Net::HTTPHeader] response headers
|
|
479
|
+
# @return [AgentHarness::QuotaStatus, nil]
|
|
480
|
+
def update_quota_from_headers(headers)
|
|
481
|
+
nil
|
|
482
|
+
end
|
|
483
|
+
|
|
450
484
|
protected
|
|
451
485
|
|
|
452
486
|
# Build CLI command - override in subclasses
|
|
@@ -1019,6 +1053,29 @@ module AgentHarness
|
|
|
1019
1053
|
output_tokens: response.tokens[:output] || 0,
|
|
1020
1054
|
total_tokens: response.tokens[:total]
|
|
1021
1055
|
)
|
|
1056
|
+
|
|
1057
|
+
# Feed the same usage into the fallback quota tracker so providers
|
|
1058
|
+
# without a proactive quota API can still estimate remaining quota from
|
|
1059
|
+
# a caller-configured billing limit. The record signatures intentionally
|
|
1060
|
+
# match so the two trackers can share this hook.
|
|
1061
|
+
AgentHarness.token_usage_tracker.record(
|
|
1062
|
+
provider: self.class.provider_name,
|
|
1063
|
+
model: response.model || @config.model,
|
|
1064
|
+
input_tokens: response.tokens[:input] || 0,
|
|
1065
|
+
output_tokens: response.tokens[:output] || 0,
|
|
1066
|
+
total_tokens: response.tokens[:total]
|
|
1067
|
+
)
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
def attach_quota_status_from_headers(response)
|
|
1071
|
+
headers = response.metadata[:headers]
|
|
1072
|
+
return response unless headers
|
|
1073
|
+
|
|
1074
|
+
quota_status = update_quota_from_headers(headers)
|
|
1075
|
+
return response unless quota_status
|
|
1076
|
+
|
|
1077
|
+
response.metadata[:quota_status] = quota_status
|
|
1078
|
+
response
|
|
1022
1079
|
end
|
|
1023
1080
|
|
|
1024
1081
|
def handle_error(error, prompt:, options:)
|
|
@@ -661,6 +661,26 @@ module AgentHarness
|
|
|
661
661
|
|
|
662
662
|
def cli_env_overrides = {"PAID_CODEX_SUBSCRIPTION_AUTH" => "1"}
|
|
663
663
|
|
|
664
|
+
# Proactively check quota for Codex's configured backend.
|
|
665
|
+
#
|
|
666
|
+
# Codex runners frequently route through OpenRouter by setting
|
|
667
|
+
# +OPENAI_BASE_URL=https://openrouter.ai/api/v1+. When the request env
|
|
668
|
+
# indicates OpenRouter, the check is delegated to
|
|
669
|
+
# {QuotaCheckers::OpenRouter}, which queries the +/credits+ endpoint.
|
|
670
|
+
# Otherwise OpenAI's quota API is not publicly exposed, so the check
|
|
671
|
+
# returns unavailable and callers fall back to {TokenUsageTracker}.
|
|
672
|
+
#
|
|
673
|
+
# @param env [Hash{String=>String}] request-scoped environment
|
|
674
|
+
# @param timeout [Numeric] time budget in seconds
|
|
675
|
+
# @return [AgentHarness::QuotaStatus]
|
|
676
|
+
def check_quota(env:, timeout: QuotaCheckers::OpenRouter::DEFAULT_TIMEOUT)
|
|
677
|
+
if QuotaCheckers::OpenRouter.routes_through_open_router?(env)
|
|
678
|
+
return QuotaCheckers::OpenRouter.check(env: env, timeout: timeout, logger: @logger)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
QuotaStatus.unavailable
|
|
682
|
+
end
|
|
683
|
+
|
|
664
684
|
def send_message(prompt:, **options)
|
|
665
685
|
super
|
|
666
686
|
ensure
|
|
@@ -10,9 +10,36 @@ module AgentHarness
|
|
|
10
10
|
# Provides integration with the Kilocode CLI tool.
|
|
11
11
|
class Kilocode < Base
|
|
12
12
|
PACKAGE_NAME = "@kilocode/cli"
|
|
13
|
-
DEFAULT_VERSION = "7.
|
|
13
|
+
DEFAULT_VERSION = "7.4.16"
|
|
14
14
|
SUPPORTED_VERSION_REQUIREMENT = "= #{DEFAULT_VERSION}"
|
|
15
15
|
STRUCTURED_EVENT_TYPES = %w[text error step_finish result usage].freeze
|
|
16
|
+
MODEL_NAMES = %w[
|
|
17
|
+
glm-5
|
|
18
|
+
glm-5.1
|
|
19
|
+
glm-5.1-free
|
|
20
|
+
glm-5.1-thinking
|
|
21
|
+
glm-5.2
|
|
22
|
+
glm-5.2-fast
|
|
23
|
+
glm-5.2-flex
|
|
24
|
+
glm-5.2-free
|
|
25
|
+
glm-5.2-nitro
|
|
26
|
+
glm-5.2-short
|
|
27
|
+
glm-5.2-short-fast
|
|
28
|
+
glm-5.2-short-fast-flex
|
|
29
|
+
glm-5.2-short-flex
|
|
30
|
+
glm-5p1
|
|
31
|
+
glm-5p1-fast
|
|
32
|
+
glm-5p2
|
|
33
|
+
glm-5p2-fast
|
|
34
|
+
glm-5v-turbo
|
|
35
|
+
].freeze
|
|
36
|
+
MODEL_CATALOG = MODEL_NAMES.map do |name|
|
|
37
|
+
tier = name.include?("free") ? "free" : "standard"
|
|
38
|
+
tier = "advanced" if %w[glm-5.1 glm-5.1-thinking glm-5.2 glm-5p1 glm-5p2].include?(name)
|
|
39
|
+
|
|
40
|
+
{name: name, family: name, tier: tier, provider: "kilocode"}.freeze
|
|
41
|
+
end.freeze
|
|
42
|
+
MODEL_FAMILY_PATTERN = /\Aglm-5(?:[a-z][\w.-]*|[.-][\w.-]+)?\z/i
|
|
16
43
|
# Kilo CLI (an OpenCode fork) ships the same external_directory
|
|
17
44
|
# permission category as OpenCode, defaulting to "ask" for anything
|
|
18
45
|
# outside the project dir. In non-interactive execution there is no
|
|
@@ -73,7 +100,20 @@ module AgentHarness
|
|
|
73
100
|
|
|
74
101
|
def discover_models
|
|
75
102
|
return [] unless available?
|
|
76
|
-
|
|
103
|
+
|
|
104
|
+
MODEL_CATALOG.map(&:dup)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def model_family(provider_model_name)
|
|
108
|
+
provider_model_name.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def provider_model_name(family_name)
|
|
112
|
+
family_name.to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def supports_model_family?(family_name)
|
|
116
|
+
MODEL_FAMILY_PATTERN.match?(family_name.to_s)
|
|
77
117
|
end
|
|
78
118
|
|
|
79
119
|
def installation_contract(version: DEFAULT_VERSION)
|
|
@@ -379,23 +419,83 @@ module AgentHarness
|
|
|
379
419
|
|
|
380
420
|
private
|
|
381
421
|
|
|
382
|
-
# Default-merge
|
|
383
|
-
# generated Kilo config
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
422
|
+
# Default-merge the permissive external_directory permission into the
|
|
423
|
+
# generated Kilo config. A caller that supplies a +permission+ block is
|
|
424
|
+
# merged ON TOP of the provider's non-interactive defaults (the container
|
|
425
|
+
# scratch + home-directory allowlist): the default +external_directory+
|
|
426
|
+
# entries are unioned with the caller's +external_directory+ entries (the
|
|
427
|
+
# caller wins on conflicting patterns) and any other caller-supplied
|
|
428
|
+
# permission category is carried through verbatim. This makes it hard to
|
|
429
|
+
# accidentally discard the provider defaults when a caller only needs to
|
|
430
|
+
# add a single +external_directory+ entry (see #310).
|
|
431
|
+
#
|
|
432
|
+
# A caller that intentionally owns the full permission block (e.g. to deny
|
|
433
|
+
# the default /tmp or home paths) can opt out of the merge with the
|
|
434
|
+
# +:permission_replace+ (or +"permission_replace"+) option, in which case
|
|
435
|
+
# the caller-supplied permission is honored verbatim and no defaults are
|
|
436
|
+
# injected. If +permission_replace+ is set but the caller does not supply
|
|
437
|
+
# a +permission+ key at all, the config is left without any injected
|
|
438
|
+
# +permission+ block.
|
|
439
|
+
#
|
|
440
|
+
# An invalid caller permission is ignored in favor of the default rule.
|
|
441
|
+
# An empty caller permission hash also falls back to the default rule,
|
|
442
|
+
# unless +permission_replace+ is set, in which case the empty hash is
|
|
443
|
+
# preserved verbatim.
|
|
389
444
|
def apply_default_external_directory_permission(config, options = {})
|
|
445
|
+
if options[:permission_replace] || options["permission_replace"]
|
|
446
|
+
if options.key?(:permission)
|
|
447
|
+
config["permission"] = deep_dup(options[:permission])
|
|
448
|
+
elsif options.key?("permission")
|
|
449
|
+
config["permission"] = deep_dup(options["permission"])
|
|
450
|
+
else
|
|
451
|
+
config.delete("permission")
|
|
452
|
+
end
|
|
453
|
+
return
|
|
454
|
+
end
|
|
455
|
+
|
|
390
456
|
caller_permission = options[:permission] || options["permission"] || config["permission"]
|
|
457
|
+
|
|
391
458
|
if caller_permission.is_a?(Hash) && !caller_permission.empty?
|
|
392
|
-
config["permission"] =
|
|
459
|
+
config["permission"] = merge_default_permission(caller_permission)
|
|
393
460
|
return
|
|
394
461
|
end
|
|
395
462
|
|
|
396
463
|
config["permission"] = deep_dup(DEFAULT_PERMISSION_CONFIG)
|
|
397
464
|
end
|
|
398
465
|
|
|
466
|
+
# Merge a caller-supplied permission block on top of the provider default
|
|
467
|
+
# permission rule. The default +external_directory+ allowlist is unioned
|
|
468
|
+
# with the caller's +external_directory+ entries (caller decisions win on
|
|
469
|
+
# conflicting patterns); every other caller-supplied permission category
|
|
470
|
+
# is carried through verbatim.
|
|
471
|
+
def merge_default_permission(caller_permission)
|
|
472
|
+
merged = deep_dup(DEFAULT_PERMISSION_CONFIG)
|
|
473
|
+
|
|
474
|
+
caller_permission.each do |category, rules|
|
|
475
|
+
if category.to_s == "external_directory"
|
|
476
|
+
merged["external_directory"] = merge_external_directory_rules(merged["external_directory"], rules)
|
|
477
|
+
else
|
|
478
|
+
merged[category.to_s] = deep_dup(rules)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
merged
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Union default external_directory rules with caller-supplied rules.
|
|
486
|
+
# Caller decisions win on overlapping patterns. A non-Hash caller value
|
|
487
|
+
# (e.g. a scalar default) cannot be meaningfully merged, so it is honored
|
|
488
|
+
# verbatim for that category.
|
|
489
|
+
def merge_external_directory_rules(default_rules, caller_rules)
|
|
490
|
+
return deep_dup(caller_rules) unless caller_rules.is_a?(Hash)
|
|
491
|
+
|
|
492
|
+
rules = deep_dup(default_rules)
|
|
493
|
+
caller_rules.each do |pattern, decision|
|
|
494
|
+
rules[pattern.to_s] = deep_dup(decision)
|
|
495
|
+
end
|
|
496
|
+
rules
|
|
497
|
+
end
|
|
498
|
+
|
|
399
499
|
def kilocode_config_path
|
|
400
500
|
"~/.config/kilocode/kilo.json"
|
|
401
501
|
end
|
|
@@ -410,7 +510,10 @@ module AgentHarness
|
|
|
410
510
|
|
|
411
511
|
payload = stringify_keys(config_extras)
|
|
412
512
|
payload["model"] = runtime.model if runtime.model
|
|
413
|
-
apply_default_external_directory_permission(payload)
|
|
513
|
+
apply_default_external_directory_permission(payload, payload)
|
|
514
|
+
# +permission_replace+ is a control flag for the merge above, not a Kilo
|
|
515
|
+
# config key, so strip it before the payload is serialized to disk.
|
|
516
|
+
payload.delete("permission_replace")
|
|
414
517
|
payload.empty? ? nil : payload
|
|
415
518
|
end
|
|
416
519
|
|
|
@@ -10,8 +10,8 @@ module AgentHarness
|
|
|
10
10
|
# Provides integration with the OpenCode CLI tool.
|
|
11
11
|
class Opencode < Base
|
|
12
12
|
CLI_PACKAGE = "opencode-ai"
|
|
13
|
-
SUPPORTED_CLI_VERSION = "1.
|
|
14
|
-
SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "<
|
|
13
|
+
SUPPORTED_CLI_VERSION = "1.18.9"
|
|
14
|
+
SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 2.0.0").freeze
|
|
15
15
|
INSTALL_COMMAND_PREFIX = ["npm", "install", "-g", "--ignore-scripts"].freeze
|
|
16
16
|
# Allowlist of external_directory patterns auto-approved in
|
|
17
17
|
# non-interactive execution. See the DEFAULT_PERMISSION_RULE comment
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module AgentHarness
|
|
8
|
+
module Providers
|
|
9
|
+
# Standalone quota-check helpers for backends that do not have a dedicated
|
|
10
|
+
# provider class but can still be queried proactively before a run starts.
|
|
11
|
+
#
|
|
12
|
+
# Today this hosts the OpenRouter credits lookup, which Paid's runners can
|
|
13
|
+
# route through even though there is no canonical +:openrouter+ provider
|
|
14
|
+
# class (it is selected via +ProviderRuntime#api_provider+). Other backends
|
|
15
|
+
# that surface through OpenAI-compatible transports but expose their own
|
|
16
|
+
# balance endpoint belong here too.
|
|
17
|
+
module QuotaCheckers
|
|
18
|
+
# Quota checker for OpenRouter's credit balance API.
|
|
19
|
+
#
|
|
20
|
+
# OpenRouter is selected by Paid runners via +ProviderRuntime+ overrides
|
|
21
|
+
# rather than a dedicated provider class, so the check is implemented as
|
|
22
|
+
# a standalone helper that any provider can call from its own
|
|
23
|
+
# +check_quota+ implementation when the request env points at OpenRouter.
|
|
24
|
+
#
|
|
25
|
+
# @example Direct usage
|
|
26
|
+
# AgentHarness::Providers::QuotaCheckers::OpenRouter.check(
|
|
27
|
+
# env: { "OPENROUTER_API_KEY" => "sk-..." }
|
|
28
|
+
# )
|
|
29
|
+
# # => #<AgentHarness::QuotaStatus available=true remaining=12.5 unit=:credits ...>
|
|
30
|
+
module OpenRouter
|
|
31
|
+
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
32
|
+
DEFAULT_TIMEOUT = 10
|
|
33
|
+
USER_AGENT = "AgentHarness/1.0"
|
|
34
|
+
HOST_FRAGMENT = "openrouter.ai"
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Detect whether a request env would route through OpenRouter.
|
|
38
|
+
#
|
|
39
|
+
# Used by providers (Codex, Kilocode, etc.) to decide whether to
|
|
40
|
+
# delegate +check_quota+ to this checker.
|
|
41
|
+
#
|
|
42
|
+
# @param env [Hash{String=>String}] request-scoped environment
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def routes_through_open_router?(env)
|
|
45
|
+
return false if env.nil?
|
|
46
|
+
|
|
47
|
+
values = env.values_at("OPENROUTER_API_KEY", :OPENROUTER_API_KEY)
|
|
48
|
+
return true if values.any? { |value| value.respond_to?(:to_str) && !value.to_str.empty? }
|
|
49
|
+
|
|
50
|
+
base_url_values = env.values_at("OPENAI_BASE_URL", :OPENAI_BASE_URL)
|
|
51
|
+
base_url_values.any? { |value| value.to_s.include?(HOST_FRAGMENT) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve the API key the OpenRouter quota endpoint should use.
|
|
55
|
+
#
|
|
56
|
+
# Order of precedence: explicit +OPENROUTER_API_KEY+, then
|
|
57
|
+
# +OPENAI_API_KEY+ (since OpenRouter accepts it under that name when
|
|
58
|
+
# routed via the OpenAI-compatible transport).
|
|
59
|
+
#
|
|
60
|
+
# @param env [Hash{String=>String}] request-scoped environment
|
|
61
|
+
# @return [String, nil]
|
|
62
|
+
def resolve_api_key(env)
|
|
63
|
+
env_value(env, "OPENROUTER_API_KEY") ||
|
|
64
|
+
env_value(env, "OPENAI_API_KEY")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Query OpenRouter's +/credits+ endpoint and return a {QuotaStatus}.
|
|
68
|
+
#
|
|
69
|
+
# @param env [Hash{String=>String}] request-scoped environment
|
|
70
|
+
# @param base_url [String, nil] override the API base URL
|
|
71
|
+
# @param timeout [Numeric] time budget in seconds
|
|
72
|
+
# @param logger [Logger, nil]
|
|
73
|
+
# @return [AgentHarness::QuotaStatus] populated when credentials are
|
|
74
|
+
# present and the API responds; unavailable otherwise
|
|
75
|
+
def check(env:, base_url: nil, timeout: DEFAULT_TIMEOUT, logger: nil)
|
|
76
|
+
api_key = resolve_api_key(env)
|
|
77
|
+
unless api_key && !api_key.empty?
|
|
78
|
+
logger&.debug("[AgentHarness::QuotaCheckers::OpenRouter] no API key present in env")
|
|
79
|
+
return QuotaStatus.unavailable
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
uri = URI.parse("#{resolve_base_url(env, base_url)}/credits")
|
|
83
|
+
response = perform_get(uri:, api_key:, timeout:, logger:)
|
|
84
|
+
return QuotaStatus.unavailable unless response
|
|
85
|
+
|
|
86
|
+
parse_credits_response(response)
|
|
87
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error, JSON::ParserError,
|
|
88
|
+
OpenSSL::SSL::SSLError => e
|
|
89
|
+
logger&.warn("[AgentHarness::QuotaCheckers::OpenRouter] credit check failed: #{e.message}")
|
|
90
|
+
QuotaStatus.unavailable
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def resolve_base_url(env, override)
|
|
96
|
+
base = override || env_value(env, "OPENROUTER_BASE_URL") || DEFAULT_BASE_URL
|
|
97
|
+
base.to_s.chomp("/")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def env_value(env, key)
|
|
101
|
+
return nil if env.nil?
|
|
102
|
+
|
|
103
|
+
env[key] || env[key.to_sym]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def perform_get(uri:, api_key:, timeout:, logger:)
|
|
107
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
108
|
+
http.use_ssl = (uri.scheme == "https")
|
|
109
|
+
http.open_timeout = timeout
|
|
110
|
+
http.read_timeout = timeout
|
|
111
|
+
|
|
112
|
+
request = Net::HTTP::Get.new(uri)
|
|
113
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
114
|
+
request["User-Agent"] = USER_AGENT
|
|
115
|
+
|
|
116
|
+
logger&.debug("[AgentHarness::QuotaCheckers::OpenRouter] GET #{uri}")
|
|
117
|
+
|
|
118
|
+
response = http.request(request)
|
|
119
|
+
return response if response.is_a?(Net::HTTPSuccess)
|
|
120
|
+
|
|
121
|
+
logger&.warn("[AgentHarness::QuotaCheckers::OpenRouter] #{uri} returned HTTP #{response.code}")
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# OpenRouter returns: {"data": {"total_credits": 20.0, "total_usage": 7.5}}
|
|
126
|
+
def parse_credits_response(response)
|
|
127
|
+
body = response.body
|
|
128
|
+
return QuotaStatus.unavailable if body.nil? || body.empty?
|
|
129
|
+
|
|
130
|
+
parsed = JSON.parse(body)
|
|
131
|
+
data = parsed.is_a?(Hash) ? parsed["data"] : nil
|
|
132
|
+
return QuotaStatus.unavailable unless data.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
total = data["total_credits"]
|
|
135
|
+
usage = data["total_usage"]
|
|
136
|
+
return QuotaStatus.unavailable if total.nil? || usage.nil?
|
|
137
|
+
|
|
138
|
+
limit = total.to_f
|
|
139
|
+
used = usage.to_f
|
|
140
|
+
remaining = limit - used
|
|
141
|
+
|
|
142
|
+
QuotaStatus.new(
|
|
143
|
+
available: true,
|
|
144
|
+
remaining: remaining,
|
|
145
|
+
limit: limit,
|
|
146
|
+
reset_at: nil,
|
|
147
|
+
unit: :credits
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
# Serializable snapshot of a provider's remaining quota for the current
|
|
7
|
+
# billing period.
|
|
8
|
+
#
|
|
9
|
+
# {QuotaStatus} is the unified return type for {Providers::Base#check_quota}
|
|
10
|
+
# and {TokenUsageTracker#estimated_usage}. It lets the orchestration layer
|
|
11
|
+
# compare runners on the same axis (remaining/limit/unit) so Paid can
|
|
12
|
+
# auto-balance weights and let multiple runners exhaust their quotas at
|
|
13
|
+
# roughly the same rate.
|
|
14
|
+
#
|
|
15
|
+
# All instances are frozen so callers can safely cache, share, and persist
|
|
16
|
+
# them (for example, to a database column) without worrying about mutation.
|
|
17
|
+
#
|
|
18
|
+
# @example A real API-backed status
|
|
19
|
+
# AgentHarness::QuotaStatus.new(
|
|
20
|
+
# available: true,
|
|
21
|
+
# remaining: 1_500_000,
|
|
22
|
+
# limit: 2_000_000,
|
|
23
|
+
# reset_at: Time.utc(2026, 8, 1),
|
|
24
|
+
# unit: :tokens
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# @example Sentinel returned by providers that do not expose a quota API
|
|
28
|
+
# AgentHarness::QuotaStatus.unavailable
|
|
29
|
+
#
|
|
30
|
+
class QuotaStatus < Struct.new(
|
|
31
|
+
:available,
|
|
32
|
+
:remaining,
|
|
33
|
+
:limit,
|
|
34
|
+
:reset_at,
|
|
35
|
+
:unit,
|
|
36
|
+
:checked_at
|
|
37
|
+
)
|
|
38
|
+
# Units recognized by the harness. Providers may return other Symbols,
|
|
39
|
+
# but the documented set is enumerated here so callers can switch on it
|
|
40
|
+
# without typos. New units should be added here when a provider exposes
|
|
41
|
+
# one that does not fit an existing bucket.
|
|
42
|
+
UNITS = %i[
|
|
43
|
+
tokens
|
|
44
|
+
requests
|
|
45
|
+
credits
|
|
46
|
+
cost_cents
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
def initialize(available: false, remaining: nil, limit: nil, reset_at: nil, unit: nil, checked_at: nil)
|
|
50
|
+
unless reset_at.nil? || reset_at.is_a?(Time)
|
|
51
|
+
raise ArgumentError, "reset_at must be a Time or nil (got #{reset_at.class})"
|
|
52
|
+
end
|
|
53
|
+
unless unit.nil? || unit.is_a?(Symbol)
|
|
54
|
+
raise ArgumentError, "unit must be a Symbol or nil (got #{unit.class})"
|
|
55
|
+
end
|
|
56
|
+
unless available == true || available == false
|
|
57
|
+
raise ArgumentError, "available must be a boolean (got #{available.class})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Snap the check time when an available status is built without one so
|
|
61
|
+
# cached/stored statuses always record when the underlying lookup ran.
|
|
62
|
+
resolved_checked_at = checked_at || (available ? Time.now.utc : nil)
|
|
63
|
+
|
|
64
|
+
super(
|
|
65
|
+
available: available,
|
|
66
|
+
remaining: remaining,
|
|
67
|
+
limit: limit,
|
|
68
|
+
reset_at: reset_at,
|
|
69
|
+
unit: unit,
|
|
70
|
+
checked_at: resolved_checked_at
|
|
71
|
+
)
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether this provider exposes any quota information at all.
|
|
76
|
+
#
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def available? = available == true
|
|
79
|
+
|
|
80
|
+
# Whether the tracked quota is exhausted. Returns false when availability
|
|
81
|
+
# is unknown so callers can treat the status conservatively.
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def exhausted?
|
|
85
|
+
return false unless available?
|
|
86
|
+
return false if remaining.nil?
|
|
87
|
+
|
|
88
|
+
remaining <= 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Human-friendly label for the +unit+ value, useful for logs and UIs.
|
|
92
|
+
#
|
|
93
|
+
# @return [String]
|
|
94
|
+
def unit_label
|
|
95
|
+
return "unknown" if unit.nil?
|
|
96
|
+
|
|
97
|
+
unit.to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Serializable hash suitable for database persistence.
|
|
101
|
+
#
|
|
102
|
+
# +reset_at+ and +checked_at+ are serialized as ISO8601 strings in UTC so
|
|
103
|
+
# the hash round-trips through JSON without losing timezone information.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash{Symbol => Object}]
|
|
106
|
+
def to_h
|
|
107
|
+
{
|
|
108
|
+
available: available,
|
|
109
|
+
remaining: remaining,
|
|
110
|
+
limit: limit,
|
|
111
|
+
reset_at: serialize_time(reset_at),
|
|
112
|
+
unit: unit,
|
|
113
|
+
checked_at: serialize_time(checked_at)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Build a QuotaStatus from a Hash produced by {#to_h}.
|
|
118
|
+
#
|
|
119
|
+
# @param hash [Hash] serialized QuotaStatus
|
|
120
|
+
# @return [QuotaStatus]
|
|
121
|
+
def self.from_h(hash)
|
|
122
|
+
return unavailable if hash.nil? || hash.empty?
|
|
123
|
+
|
|
124
|
+
new(
|
|
125
|
+
available: fetch_value(hash, :available),
|
|
126
|
+
remaining: fetch_value(hash, :remaining),
|
|
127
|
+
limit: fetch_value(hash, :limit),
|
|
128
|
+
reset_at: parse_time(fetch_value(hash, :reset_at)),
|
|
129
|
+
unit: normalize_unit(fetch_value(hash, :unit)),
|
|
130
|
+
checked_at: parse_time(fetch_value(hash, :checked_at))
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.fetch_value(hash, key)
|
|
135
|
+
symbol_value = hash[key]
|
|
136
|
+
return symbol_value unless symbol_value.nil?
|
|
137
|
+
|
|
138
|
+
hash[key.to_s]
|
|
139
|
+
end
|
|
140
|
+
private_class_method :fetch_value
|
|
141
|
+
|
|
142
|
+
# Sentinel for "this provider does not expose a quota API." The default
|
|
143
|
+
# implementation of {Providers::Base#check_quota} returns this so callers
|
|
144
|
+
# can treat all providers uniformly.
|
|
145
|
+
#
|
|
146
|
+
# @return [QuotaStatus]
|
|
147
|
+
def self.unavailable
|
|
148
|
+
new(available: false)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.normalize_unit(value)
|
|
152
|
+
return nil if value.nil?
|
|
153
|
+
|
|
154
|
+
value.to_sym
|
|
155
|
+
end
|
|
156
|
+
private_class_method :normalize_unit
|
|
157
|
+
|
|
158
|
+
def self.parse_time(value)
|
|
159
|
+
return nil if value.nil?
|
|
160
|
+
|
|
161
|
+
Time.parse(value).utc
|
|
162
|
+
rescue ArgumentError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
private_class_method :parse_time
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def serialize_time(value)
|
|
170
|
+
return nil unless value.is_a?(Time)
|
|
171
|
+
|
|
172
|
+
value.utc.iso8601
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|