agent-harness 0.5.5 → 0.5.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc338c5fc81d4175149d405d494936b68a261a637deda4fc0e4fb7b18944bf67
4
- data.tar.gz: aed5c92bc22dadab8826b919e8eabf606bcd7f6bfe0e1d02631c83461056a888
3
+ metadata.gz: 946d7c425aff8c96536bc30def7e0abdba7ff7d82020f4941674a8c93be63526
4
+ data.tar.gz: ffc9d707f89ab60bf9cc59b4e5ccbcd2570a3aaa07e97c5a10bfb731f5dc07a0
5
5
  SHA512:
6
- metadata.gz: 1d662f4ae796d88a1a2c2eabce4604a38c4b53b545640b51c16a4b8e370ddf59f40ff57b19dfb46ba96e50b85399b846c9d2379bdc9806bd40aa78b1f18c1f66
7
- data.tar.gz: 913df22acc91cd6db4ff2788867dc8337a2f1e94c0a2b2cce483e0af4ec73d2a946fcbb80270968d82c49c55aa375da86c386f4c8472ad2d42664d2bc1242ee6
6
+ metadata.gz: c7b0dcef83c7a31be09a87884211a8ba03c0c93fb845e666423cd17eb70a358dd122db7e57ce04271fa5e114013adbc0007394211286c23b03cfa6a2a600c68f
7
+ data.tar.gz: a8f14eb24039afd0a93ec1eb064b59ebbe6d03aafd61ab1859c64729d2253140afebacc5c8ee761578337c671c074425784c33676808534cdf7b8ad809a2df32
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.5"
2
+ ".": "0.5.6"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.6](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.5...agent-harness/v0.5.6) (2026-03-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 53: Expose provider configuration capabilities for app-driven provider setup UIs ([#57](https://github.com/viamin/agent-harness/issues/57)) ([6aa6a02](https://github.com/viamin/agent-harness/commit/6aa6a02da14feefcad8761302d5fa8b5642a57fe))
9
+ * 54: Add per-request provider runtime overrides for CLI-backed providers ([#55](https://github.com/viamin/agent-harness/issues/55)) ([407467a](https://github.com/viamin/agent-harness/commit/407467a6965a01494e2c4590680b2bb9ddac6dce))
10
+
3
11
  ## [0.5.5](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.4...agent-harness/v0.5.5) (2026-03-29)
4
12
 
5
13
 
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ # Normalized runtime configuration for per-request provider overrides.
5
+ #
6
+ # ProviderRuntime lets callers pass a single, provider-agnostic payload
7
+ # into +send_message+ that each provider materializes into CLI args, env
8
+ # vars, or config files as needed.
9
+ #
10
+ # @example Routing OpenCode through OpenRouter with a specific model
11
+ # runtime = AgentHarness::ProviderRuntime.new(
12
+ # model: "anthropic/claude-opus-4.1",
13
+ # base_url: "https://openrouter.ai/api/v1",
14
+ # api_provider: "openrouter",
15
+ # env: { "OPENROUTER_API_KEY" => "sk-..." }
16
+ # )
17
+ # provider.send_message(prompt: "Hello", provider_runtime: runtime)
18
+ #
19
+ # @example Passing a Hash (auto-coerced by Base#send_message)
20
+ # provider.send_message(
21
+ # prompt: "Hello",
22
+ # provider_runtime: {
23
+ # model: "openai/gpt-5.3-codex",
24
+ # base_url: "https://openrouter.ai/api/v1"
25
+ # }
26
+ # )
27
+ class ProviderRuntime
28
+ attr_reader :model, :base_url, :api_provider, :env, :flags, :metadata
29
+
30
+ # @param model [String, nil] model identifier override
31
+ # @param base_url [String, nil] upstream API base URL override
32
+ # @param api_provider [String, nil] API-compatible backend name
33
+ # @param env [Hash<String,String>] extra environment variables for the subprocess
34
+ # @param flags [Array<String>] extra CLI flags to append
35
+ # @param metadata [Hash] arbitrary provider-specific data
36
+ def initialize(model: nil, base_url: nil, api_provider: nil, env: {}, flags: [], metadata: {})
37
+ @model = model
38
+ @base_url = base_url
39
+ @api_provider = api_provider
40
+
41
+ env_hash = env || {}
42
+ unless env_hash.is_a?(Hash)
43
+ raise ArgumentError, "env must be a Hash (got #{env_hash.class})"
44
+ end
45
+ normalized_env = env_hash.each_with_object({}) do |(key, value), acc|
46
+ string_key = key.to_s
47
+ unless value.is_a?(String)
48
+ raise ArgumentError, "env value for #{string_key.inspect} must be a String (got #{value.class})"
49
+ end
50
+ acc[string_key] = value
51
+ end
52
+ @env = normalized_env.freeze
53
+
54
+ normalized_flags = flags || []
55
+ unless normalized_flags.is_a?(Array)
56
+ raise ArgumentError, "flags must be an Array (got #{normalized_flags.class})"
57
+ end
58
+ normalized_flags = normalized_flags.dup
59
+ normalized_flags.each_with_index do |flag, index|
60
+ unless flag.is_a?(String)
61
+ raise ArgumentError,
62
+ "flags must be an Array of Strings; invalid element at index #{index}: #{flag.inspect} (#{flag.class})"
63
+ end
64
+ end
65
+ @flags = normalized_flags.freeze
66
+
67
+ metadata_hash = metadata || {}
68
+ unless metadata_hash.is_a?(Hash)
69
+ raise ArgumentError, "metadata must be a Hash (got #{metadata_hash.class})"
70
+ end
71
+ @metadata = metadata_hash.dup.freeze
72
+
73
+ freeze
74
+ end
75
+
76
+ # Build a ProviderRuntime from a Hash.
77
+ #
78
+ # @param hash [Hash] runtime attributes
79
+ # @return [ProviderRuntime]
80
+ def self.from_hash(hash)
81
+ raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
82
+
83
+ new(
84
+ model: hash[:model] || hash["model"],
85
+ base_url: hash[:base_url] || hash["base_url"],
86
+ api_provider: hash[:api_provider] || hash["api_provider"],
87
+ env: hash[:env] || hash["env"] || {},
88
+ flags: hash[:flags] || hash["flags"] || [],
89
+ metadata: hash[:metadata] || hash["metadata"] || {}
90
+ )
91
+ end
92
+
93
+ # Coerce a value into a ProviderRuntime.
94
+ #
95
+ # @param value [ProviderRuntime, Hash, nil] input
96
+ # @return [ProviderRuntime, nil]
97
+ def self.wrap(value)
98
+ case value
99
+ when ProviderRuntime then value
100
+ when Hash then from_hash(value)
101
+ when nil then nil
102
+ else
103
+ raise ArgumentError, "Cannot coerce #{value.class} into ProviderRuntime"
104
+ end
105
+ end
106
+
107
+ # Whether any meaningful overrides are present.
108
+ #
109
+ # @return [Boolean]
110
+ def empty?
111
+ model.nil? && base_url.nil? && api_provider.nil? &&
112
+ env.empty? && flags.empty? && metadata.empty?
113
+ end
114
+ end
115
+ end
@@ -75,11 +75,32 @@ module AgentHarness
75
75
  # @option options [Integer] :timeout timeout in seconds
76
76
  # @option options [String] :session session identifier
77
77
  # @option options [Boolean] :dangerous_mode skip permission checks
78
+ # @option options [ProviderRuntime, Hash, nil] :provider_runtime per-request
79
+ # runtime overrides (model, base_url, api_provider, env, flags, metadata).
80
+ # For providers that delegate to Providers::Base#send_message, a plain Hash
81
+ # is automatically coerced into a ProviderRuntime. Providers that override
82
+ # #send_message directly are responsible for handling this option.
78
83
  # @return [Response] response object with output and metadata
79
84
  def send_message(prompt:, **options)
80
85
  raise NotImplementedError, "#{self.class} must implement #send_message"
81
86
  end
82
87
 
88
+ # Provider configuration schema for app-driven setup UIs
89
+ #
90
+ # Returns metadata describing the configurable fields, supported
91
+ # authentication modes, and backend compatibility for this provider.
92
+ # Applications use this to build generic provider-entry forms without
93
+ # hardcoding provider-specific knowledge.
94
+ #
95
+ # @return [Hash] with :fields, :auth_modes, :openai_compatible keys
96
+ def configuration_schema
97
+ {
98
+ fields: [],
99
+ auth_modes: [auth_type],
100
+ openai_compatible: false
101
+ }
102
+ end
103
+
83
104
  # Provider capabilities
84
105
  #
85
106
  # @return [Hash] capability flags
@@ -59,6 +59,23 @@ module AgentHarness
59
59
  "Aider"
60
60
  end
61
61
 
62
+ def configuration_schema
63
+ {
64
+ fields: [
65
+ {
66
+ name: :model,
67
+ type: :string,
68
+ label: "Model",
69
+ required: false,
70
+ hint: "Model identifier (supports OpenAI, Anthropic, and other model names)",
71
+ accepts_arbitrary: true
72
+ }
73
+ ],
74
+ auth_modes: [:api_key],
75
+ openai_compatible: false
76
+ }
77
+ end
78
+
62
79
  def capabilities
63
80
  {
64
81
  streaming: true,
@@ -160,6 +160,23 @@ module AgentHarness
160
160
  "Anthropic Claude CLI"
161
161
  end
162
162
 
163
+ def configuration_schema
164
+ {
165
+ fields: [
166
+ {
167
+ name: :model,
168
+ type: :string,
169
+ label: "Model",
170
+ required: false,
171
+ hint: "Claude model to use (e.g. claude-3-5-sonnet-20241022)",
172
+ accepts_arbitrary: false
173
+ }
174
+ ],
175
+ auth_modes: [:oauth],
176
+ openai_compatible: false
177
+ }
178
+ end
179
+
163
180
  def capabilities
164
181
  {
165
182
  streaming: true,
@@ -87,10 +87,16 @@ module AgentHarness
87
87
  #
88
88
  # @param prompt [String] the prompt to send
89
89
  # @param options [Hash] additional options
90
+ # @option options [ProviderRuntime, Hash, nil] :provider_runtime per-request
91
+ # runtime overrides (model, base_url, api_provider, env, flags, metadata).
92
+ # A plain Hash is automatically coerced into a ProviderRuntime.
90
93
  # @return [Response] the response
91
94
  def send_message(prompt:, **options)
92
95
  log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
93
96
 
97
+ # Coerce provider_runtime from Hash if needed
98
+ options = normalize_provider_runtime(options)
99
+
94
100
  # Normalize and validate MCP servers
95
101
  options = normalize_mcp_servers(options)
96
102
  validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
@@ -108,6 +114,23 @@ module AgentHarness
108
114
 
109
115
  # Parse response
110
116
  response = parse_response(result, duration: duration)
117
+ runtime = options[:provider_runtime]
118
+ # Runtime model is a per-request override and always takes precedence
119
+ # over both the config-level model and whatever parse_response returned.
120
+ # This is intentional: callers use runtime overrides to route a single
121
+ # provider instance through different backends on each request.
122
+ if runtime&.model
123
+ response = Response.new(
124
+ output: response.output,
125
+ exit_code: response.exit_code,
126
+ duration: response.duration,
127
+ provider: response.provider,
128
+ model: runtime.model,
129
+ tokens: response.tokens,
130
+ metadata: response.metadata,
131
+ error: response.error
132
+ )
133
+ end
111
134
 
112
135
  # Track tokens
113
136
  track_tokens(response) if response.tokens
@@ -158,10 +181,16 @@ module AgentHarness
158
181
 
159
182
  # Build environment variables - override in subclasses
160
183
  #
184
+ # Provider subclasses should call +super+ and merge their own env vars
185
+ # so that ProviderRuntime env overrides are always included.
186
+ #
161
187
  # @param options [Hash] options
162
188
  # @return [Hash] environment variables
163
189
  def build_env(options)
164
- {}
190
+ runtime = options[:provider_runtime]
191
+ return {} unless runtime
192
+
193
+ runtime.env.dup
165
194
  end
166
195
 
167
196
  # Parse CLI output into Response - override in subclasses
@@ -211,6 +240,13 @@ module AgentHarness
211
240
 
212
241
  private
213
242
 
243
+ def normalize_provider_runtime(options)
244
+ raw = options[:provider_runtime]
245
+ return options if raw.nil? || raw.is_a?(ProviderRuntime)
246
+
247
+ options.merge(provider_runtime: ProviderRuntime.wrap(raw))
248
+ end
249
+
214
250
  def normalize_mcp_servers(options)
215
251
  servers = options[:mcp_servers]
216
252
  return options if servers.nil?
@@ -252,7 +288,7 @@ module AgentHarness
252
288
 
253
289
  AgentHarness.token_tracker.record(
254
290
  provider: self.class.provider_name,
255
- model: @config.model,
291
+ model: response.model || @config.model,
256
292
  input_tokens: response.tokens[:input] || 0,
257
293
  output_tokens: response.tokens[:output] || 0,
258
294
  total_tokens: response.tokens[:total]
@@ -59,6 +59,14 @@ module AgentHarness
59
59
  "OpenAI Codex CLI"
60
60
  end
61
61
 
62
+ def configuration_schema
63
+ {
64
+ fields: [],
65
+ auth_modes: [:api_key],
66
+ openai_compatible: true
67
+ }
68
+ end
69
+
62
70
  def capabilities
63
71
  {
64
72
  streaming: false,
@@ -211,11 +219,26 @@ module AgentHarness
211
219
  cmd += session_flags(options[:session])
212
220
  end
213
221
 
222
+ runtime = options[:provider_runtime]
223
+ if runtime
224
+ cmd += ["--model", runtime.model] if runtime.model
225
+ cmd += runtime.flags unless runtime.flags.empty?
226
+ end
227
+
214
228
  cmd << prompt
215
229
 
216
230
  cmd
217
231
  end
218
232
 
233
+ def build_env(options)
234
+ env = super
235
+ runtime = options[:provider_runtime]
236
+ return env unless runtime
237
+
238
+ env["OPENAI_BASE_URL"] = runtime.base_url if runtime.base_url
239
+ env
240
+ end
241
+
219
242
  def default_timeout
220
243
  300
221
244
  end
@@ -93,6 +93,14 @@ module AgentHarness
93
93
  "Cursor AI"
94
94
  end
95
95
 
96
+ def configuration_schema
97
+ {
98
+ fields: [],
99
+ auth_modes: [:oauth],
100
+ openai_compatible: false
101
+ }
102
+ end
103
+
96
104
  def capabilities
97
105
  {
98
106
  streaming: false,
@@ -163,23 +171,44 @@ module AgentHarness
163
171
  def send_message(prompt:, **options)
164
172
  log_debug("send_message_start", prompt_length: prompt.length, options: options.keys)
165
173
 
174
+ # Coerce provider_runtime from Hash if needed (same as Base#send_message)
175
+ options = normalize_provider_runtime(options)
176
+ runtime = options[:provider_runtime]
177
+
166
178
  # Normalize and validate MCP servers (same as Base#send_message)
167
179
  options = normalize_mcp_servers(options)
168
180
  validate_mcp_servers!(options[:mcp_servers]) if options[:mcp_servers]&.any?
169
181
 
170
182
  # Build command (without prompt in args - we send via stdin)
171
183
  command = [self.class.binary_name, "-p"]
184
+ command.concat(runtime.flags) if runtime&.flags&.any?
172
185
 
173
186
  # Calculate timeout
174
187
  timeout = options[:timeout] || @config.timeout || default_timeout
175
188
 
176
189
  # Execute command with prompt on stdin
190
+ env = build_env(options)
177
191
  start_time = Time.now
178
- result = @executor.execute(command, timeout: timeout, stdin_data: prompt)
192
+ result = @executor.execute(command, timeout: timeout, stdin_data: prompt, env: env)
179
193
  duration = Time.now - start_time
180
194
 
181
195
  # Parse response
182
196
  response = parse_response(result, duration: duration)
197
+ # Runtime model is a per-request override and always takes precedence
198
+ # over both the config-level model and whatever parse_response returned.
199
+ # See Base#send_message for rationale.
200
+ if runtime&.model
201
+ response = Response.new(
202
+ output: response.output,
203
+ exit_code: response.exit_code,
204
+ duration: response.duration,
205
+ provider: response.provider,
206
+ model: runtime.model,
207
+ tokens: response.tokens,
208
+ metadata: response.metadata,
209
+ error: response.error
210
+ )
211
+ end
183
212
 
184
213
  # Track tokens
185
214
  track_tokens(response) if response.tokens
@@ -201,7 +230,7 @@ module AgentHarness
201
230
  end
202
231
 
203
232
  def build_env(options)
204
- {}
233
+ super
205
234
  end
206
235
 
207
236
  def default_timeout
@@ -83,6 +83,25 @@ module AgentHarness
83
83
  "Google Gemini"
84
84
  end
85
85
 
86
+ def configuration_schema
87
+ {
88
+ fields: [
89
+ {
90
+ name: :model,
91
+ type: :string,
92
+ label: "Model",
93
+ required: false,
94
+ hint: "Gemini model to use (e.g. gemini-2.5-pro, gemini-2.0-flash)",
95
+ # accepts_arbitrary is true because supports_model_family? accepts
96
+ # any string starting with "gemini-", not just discovered models.
97
+ accepts_arbitrary: true
98
+ }
99
+ ],
100
+ auth_modes: [:api_key, :oauth],
101
+ openai_compatible: false
102
+ }
103
+ end
104
+
86
105
  def capabilities
87
106
  {
88
107
  streaming: true,
@@ -77,6 +77,14 @@ module AgentHarness
77
77
  "GitHub Copilot CLI"
78
78
  end
79
79
 
80
+ def configuration_schema
81
+ {
82
+ fields: [],
83
+ auth_modes: [:oauth],
84
+ openai_compatible: false
85
+ }
86
+ end
87
+
80
88
  def capabilities
81
89
  {
82
90
  streaming: false,
@@ -47,6 +47,14 @@ module AgentHarness
47
47
  "OpenCode CLI"
48
48
  end
49
49
 
50
+ def configuration_schema
51
+ {
52
+ fields: [],
53
+ auth_modes: [:api_key],
54
+ openai_compatible: true
55
+ }
56
+ end
57
+
50
58
  def capabilities
51
59
  {
52
60
  streaming: false,
@@ -80,10 +88,25 @@ module AgentHarness
80
88
 
81
89
  def build_command(prompt, options)
82
90
  cmd = [self.class.binary_name, "run"]
91
+
92
+ runtime = options[:provider_runtime]
93
+ if runtime
94
+ cmd += runtime.flags unless runtime.flags.empty?
95
+ end
96
+
83
97
  cmd << prompt
84
98
  cmd
85
99
  end
86
100
 
101
+ def build_env(options)
102
+ env = super
103
+ runtime = options[:provider_runtime]
104
+ return env unless runtime
105
+
106
+ env["OPENAI_BASE_URL"] = runtime.base_url if runtime.base_url
107
+ env
108
+ end
109
+
87
110
  def default_timeout
88
111
  300
89
112
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.5.5"
4
+ VERSION = "0.5.6"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -138,6 +138,7 @@ end
138
138
  # Core components
139
139
  require_relative "agent_harness/errors"
140
140
  require_relative "agent_harness/mcp_server"
141
+ require_relative "agent_harness/provider_runtime"
141
142
  require_relative "agent_harness/configuration"
142
143
  require_relative "agent_harness/command_executor"
143
144
  require_relative "agent_harness/docker_command_executor"
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.5.5
4
+ version: 0.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -92,6 +92,7 @@ files:
92
92
  - lib/agent_harness/orchestration/provider_manager.rb
93
93
  - lib/agent_harness/orchestration/rate_limiter.rb
94
94
  - lib/agent_harness/provider_health_check.rb
95
+ - lib/agent_harness/provider_runtime.rb
95
96
  - lib/agent_harness/providers/adapter.rb
96
97
  - lib/agent_harness/providers/aider.rb
97
98
  - lib/agent_harness/providers/anthropic.rb