agent-harness 0.36.24 → 0.37.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a501dcf4ece87de9e617366814b962f131f819c292c81dd6b31edd05e0a3e2dc
4
- data.tar.gz: 91411a412a29e9ad2eac69b569eafe06dc3f33ed7017e8669b4b88cb3f73a692
3
+ metadata.gz: 045e59747b5b64528739836b362b75b21df461fc7955d47506b410a77aabdc01
4
+ data.tar.gz: 848b47345c5c93bad14bc892a2c8fe9b8c779e81e924e0ee381204581cd66ec9
5
5
  SHA512:
6
- metadata.gz: '09b4723c8a02567e02b20a0d0c67ef411a854a533df3e57b35feaa47621a9d61eec1fce1df9d1f284b8fb2b442ab76ad7c326cfb4998611d4ad2fddc3c9bd867'
7
- data.tar.gz: 3460bd1b2cddb6d10a1152bf23643d8e30cefa6ba1eb6f5d7834730e36335fff782a8a59303cbf96a22ef97c56b58c489d12fab1d9b80da601a042d707a6311b
6
+ metadata.gz: e5df080c3c51a1992ed0a51d97f755e4d2c761a5903f67aaf72635a3085eb0a8d4e0c87d4765ddad765234f660be2d814b7cebee0b83be3633adce170690d4d9
7
+ data.tar.gz: 5d8c6ae0a2537011966fc607654a3d2807be8bceb7d1a730415e0e5ddee3a9faa54dd4e683d13c35cc3def5d6c3b9ab97c4293d00ef8ae174692369750252a86
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.36.24"
2
+ ".": "0.37.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@
5
5
  * add runner model compatibility contract (`AgentHarness.model_compatibility`) with structured `ModelCompatibility::Result` outcomes. Codex exposes static facts for CLI-gated models (e.g. `gpt-5.5` requires Codex CLI `>= 0.116.0`), a baseline supported-model list, supported auth modes, and a `DEFAULT_COMPATIBLE_MODEL_ID` fallback so downstream orchestrators can validate tier/model assignments before scheduling agent runs ([#259](https://github.com/viamin/agent-harness/issues/259)).
6
6
  * **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
7
7
 
8
+ ## [0.37.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.36.24...agent-harness/v0.37.0) (2026-09-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **codex:** expose subscription-aware model discovery and bounded rejection recovery ([#414](https://github.com/viamin/agent-harness/issues/414)) ([c7f52b3](https://github.com/viamin/agent-harness/commit/c7f52b32bb00f8ba988cd0656749b70546043b78))
14
+
8
15
  ## [0.36.24](https://github.com/viamin/agent-harness/compare/agent-harness/v0.36.23...agent-harness/v0.36.24) (2026-09-16)
9
16
 
10
17
 
@@ -203,6 +203,20 @@ module AgentHarness
203
203
  !which(binary).nil?
204
204
  end
205
205
 
206
+ # Execute a command while allowing a caller to exchange messages over its
207
+ # stdio before stdin is closed. The block must consume the stdout it needs
208
+ # and return it; any remaining stdout is appended after the exchange.
209
+ def execute_interactive(command, timeout: nil, env: {})
210
+ validate_duration!(timeout, name: :timeout, allow_nil: true)
211
+ cmd_array = normalize_command(command)
212
+ start_time = current_time
213
+
214
+ stdout, stderr, status = interactive_process(cmd_array, timeout:, env:) do |stdin, stdout_io|
215
+ yield(stdin, stdout_io)
216
+ end
217
+ Result.new(stdout:, stderr:, exit_code: status.exitstatus, duration: current_time - start_time)
218
+ end
219
+
206
220
  protected
207
221
 
208
222
  def normalize_command(command)
@@ -218,6 +232,38 @@ module AgentHarness
218
232
 
219
233
  private
220
234
 
235
+ def interactive_process(cmd_array, timeout:, env:)
236
+ Open3.popen3(env, *cmd_array, pgroup: true) do |stdin, stdout_io, stderr_io, wait_thr|
237
+ stderr_reader = Thread.new { stderr_io.read }
238
+ begin
239
+ stdout, status = run_interactive_exchange(stdin, stdout_io, wait_thr, cmd_array, timeout) do
240
+ yield(stdin, stdout_io)
241
+ end
242
+ [stdout, stderr_reader.value, status]
243
+ rescue
244
+ terminate_process(wait_thr) if wait_thr.alive?
245
+ raise
246
+ ensure
247
+ stdin.close unless stdin.closed?
248
+ stderr_reader.join
249
+ end
250
+ end
251
+ end
252
+
253
+ def run_interactive_exchange(stdin, stdout_io, wait_thr, cmd_array, timeout)
254
+ exchange = proc do
255
+ consumed_stdout = yield.to_s
256
+ stdin.close unless stdin.closed?
257
+ [consumed_stdout + stdout_io.read, wait_thr.value]
258
+ end
259
+ return exchange.call unless timeout
260
+
261
+ Timeout.timeout(timeout, &exchange)
262
+ rescue Timeout::Error
263
+ terminate_process(wait_thr)
264
+ raise TimeoutError, "Command timed out after #{timeout} seconds: #{cmd_array.first}"
265
+ end
266
+
221
267
  def acquire_preparation_locks(preparation, env:, timeout:, deadline:, command_name:)
222
268
  return [] if preparation.nil? || preparation.empty?
223
269
 
@@ -189,6 +189,16 @@ module AgentHarness
189
189
  result.success? ? result.stdout.strip : nil
190
190
  end
191
191
 
192
+ def execute_interactive(command, timeout: nil, env: {}, &exchange)
193
+ docker_cmd = build_docker_command_for_execution(
194
+ normalize_command(command),
195
+ env: env,
196
+ stdin_data: "",
197
+ execution_tracking: nil
198
+ )
199
+ super(docker_cmd, timeout:, env: {}, &exchange)
200
+ end
201
+
192
202
  private
193
203
 
194
204
  def preparation_lock_scope
@@ -338,13 +338,26 @@ module AgentHarness
338
338
  end
339
339
  smoke = provider_instance.smoke_test(timeout: smoke_timeout, provider_runtime: provider_runtime)
340
340
  unless smoke[:ok]
341
+ recovered = recover_smoke_test_model_rejection(
342
+ provider_instance,
343
+ smoke,
344
+ provider_runtime,
345
+ provider_name: provider_name,
346
+ start_time: start_time,
347
+ discovery_timeout: smoke_timeout || timeout,
348
+ smoke_timeout: smoke_timeout
349
+ )
350
+ return recovered if recovered
351
+
341
352
  return build_result(
342
353
  name: provider_name,
343
354
  status: smoke[:status] || "error",
344
355
  message: smoke[:message] || "Smoke test failed",
345
356
  start_time: start_time,
346
357
  error_category: normalize_smoke_error_category(smoke[:error_category], smoke[:message]),
347
- check: :smoke_test
358
+ check: :smoke_test,
359
+ model: smoke[:model],
360
+ recovery: smoke[:recovery]
348
361
  )
349
362
  end
350
363
 
@@ -376,7 +389,71 @@ module AgentHarness
376
389
  status: "ok",
377
390
  message: message,
378
391
  start_time: start_time,
379
- check: :smoke_test
392
+ check: :smoke_test,
393
+ model: smoke[:model]
394
+ )
395
+ end
396
+
397
+ def recover_smoke_test_model_rejection(provider_instance, smoke, provider_runtime, provider_name:, start_time:,
398
+ discovery_timeout:, smoke_timeout:)
399
+ return unless provider_instance.respond_to?(:resolve_model_rejection_recovery)
400
+
401
+ env = build_preflight_env(provider_instance, provider_runtime)
402
+ recovery = provider_instance.resolve_model_rejection_recovery(
403
+ failure: smoke,
404
+ provider_runtime: provider_runtime,
405
+ env: env,
406
+ timeout: discovery_timeout
407
+ )
408
+ return unless recovery
409
+
410
+ runtime = recovery[:provider_runtime]
411
+ unless runtime
412
+ failure = recovery_failure_result(smoke, recovery)
413
+ return build_smoke_result(provider_name, failure, start_time)
414
+ end
415
+
416
+ retried_smoke = provider_instance.smoke_test(timeout: smoke_timeout, provider_runtime: runtime)
417
+ if retried_smoke[:ok]
418
+ success = recovery_success_result(retried_smoke, recovery)
419
+ return build_smoke_result(provider_name, success, start_time)
420
+ end
421
+
422
+ failure = recovery_failure_result(retried_smoke.merge(original_failure: smoke), recovery)
423
+ build_smoke_result(provider_name, failure, start_time)
424
+ end
425
+
426
+ def recovery_success_result(smoke, recovery)
427
+ {
428
+ status: "ok",
429
+ message: "Smoke test passed after Codex model replacement",
430
+ error_category: nil,
431
+ check: :smoke_test,
432
+ model: smoke[:model],
433
+ recovery: recovery.except(:provider_runtime).merge(outcome: :recovered)
434
+ }
435
+ end
436
+
437
+ def recovery_failure_result(smoke, recovery)
438
+ smoke.merge(
439
+ recovery: recovery.except(:provider_runtime).merge(outcome: :unrecovered),
440
+ message: [
441
+ smoke[:message],
442
+ recovery[:message]
443
+ ].compact.join(" ")
444
+ )
445
+ end
446
+
447
+ def build_smoke_result(provider_name, smoke, start_time)
448
+ build_result(
449
+ name: provider_name,
450
+ status: smoke[:status] || "error",
451
+ message: smoke[:message] || "Smoke test failed",
452
+ start_time: start_time,
453
+ error_category: normalize_smoke_error_category(smoke[:error_category], smoke[:message]),
454
+ check: :smoke_test,
455
+ model: smoke[:model],
456
+ recovery: smoke[:recovery]
380
457
  )
381
458
  end
382
459
 
@@ -439,6 +516,8 @@ module AgentHarness
439
516
  :rate_limit
440
517
  when :quota_exceeded, :quota
441
518
  :quota
519
+ when :subscription_model_rejected
520
+ :subscription_model_rejected
442
521
  when :timeout
443
522
  :timeout
444
523
  when :transient
@@ -479,7 +558,7 @@ module AgentHarness
479
558
  provider_instance.method(method_name).owner != Providers::Adapter
480
559
  end
481
560
 
482
- def build_result(name:, status:, message:, start_time:, error_category: nil, check: nil)
561
+ def build_result(name:, status:, message:, start_time:, error_category: nil, check: nil, **metadata)
483
562
  latency = ((monotonic_now - start_time) * 1000).round
484
563
  {
485
564
  name: name,
@@ -488,7 +567,7 @@ module AgentHarness
488
567
  latency_ms: latency,
489
568
  error_category: error_category,
490
569
  check: check
491
- }
570
+ }.merge(metadata.compact)
492
571
  end
493
572
 
494
573
  def build_provider(provider_name, klass, executor:)
@@ -1199,7 +1199,8 @@ module AgentHarness
1199
1199
  message: contract[:success_message] || "Smoke test passed",
1200
1200
  error_category: nil,
1201
1201
  output: output,
1202
- exit_code: response.exit_code
1202
+ exit_code: response.exit_code,
1203
+ model: response.model
1203
1204
  }
1204
1205
  end
1205
1206
 
@@ -1213,7 +1214,8 @@ module AgentHarness
1213
1214
  message: message,
1214
1215
  error_category: classify_smoke_test_message(message),
1215
1216
  output: output,
1216
- exit_code: response.exit_code
1217
+ exit_code: response.exit_code,
1218
+ model: response.model
1217
1219
  }
1218
1220
  rescue TimeoutError => e
1219
1221
  failure_smoke_test_result(e.message, :timeout)
@@ -17,8 +17,32 @@ module AgentHarness
17
17
  :type, :turn, :tokens, :error_message, :tool_name, :raw_event
18
18
  )
19
19
 
20
+ ModelDiscovery = Struct.new(
21
+ :status, :recommended_model_id, :models, :source, :reason, :details
22
+ ) do
23
+ def available? = status == :available
24
+
25
+ def to_h
26
+ {
27
+ status: status,
28
+ recommended_model_id: recommended_model_id,
29
+ models: models,
30
+ source: source,
31
+ reason: reason,
32
+ details: details
33
+ }.compact
34
+ end
35
+ end
36
+
20
37
  SUPPORTED_CLI_VERSION = "0.149.1"
21
38
  SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 0.150.0").freeze
39
+ MODEL_REJECTION_CACHE_TTL = 300
40
+ MODEL_REJECTION_CACHE_LIMIT = 128
41
+ MODEL_REJECTION_CACHE = {}
42
+ MODEL_REJECTION_PATTERNS = [
43
+ /The ['"](?<model>[^'"]+)['"] model is not supported when using Codex with a ChatGPT account/i,
44
+ /model ['"](?<model>[^'"]+)['"] is not supported.*ChatGPT account/i
45
+ ].freeze
22
46
 
23
47
  # Default model recommended by the Codex runner contract when callers
24
48
  # have no explicit preference. Used as the {AgentHarness::ModelCompatibility::Result#fallback_model_id}
@@ -144,6 +168,31 @@ module AgentHarness
144
168
  ],
145
169
  transient_error: OAUTH_REFRESH_TRANSIENT_PATTERNS + SHARED_OUTPUT_ERROR_PATTERNS[:transient_error]
146
170
  ).tap { |h| h.each_value(&:freeze) }.freeze
171
+ MODEL_LIST_REQUESTS = [
172
+ {
173
+ method: "initialize",
174
+ id: 1,
175
+ params: {
176
+ clientInfo: {
177
+ name: "agent_harness",
178
+ title: "Agent Harness",
179
+ version: "0.1.0"
180
+ }
181
+ }
182
+ },
183
+ {
184
+ method: "initialized",
185
+ params: {}
186
+ },
187
+ {
188
+ method: "model/list",
189
+ id: 2,
190
+ params: {
191
+ limit: 100,
192
+ includeHidden: false
193
+ }
194
+ }
195
+ ].freeze
147
196
 
148
197
  class << self
149
198
  def provider_name
@@ -493,6 +542,10 @@ module AgentHarness
493
542
  nil
494
543
  end
495
544
 
545
+ def classify_model_rejection(output, configured_model: nil)
546
+ parser_instance.send(:classify_model_rejection, output, configured_model: configured_model)
547
+ end
548
+
496
549
  private
497
550
 
498
551
  def classify_stdout_chunk(text, buffer)
@@ -687,6 +740,27 @@ module AgentHarness
687
740
  QuotaStatus.unavailable
688
741
  end
689
742
 
743
+ def resolve_model_rejection_recovery(failure:, provider_runtime:, env:, timeout:)
744
+ rejection = model_rejection_from_failure(failure, provider_runtime)
745
+ return unless rejection
746
+
747
+ record_model_rejection(rejection, env: env)
748
+ allowed_model_ids = recovery_allowed_model_ids(provider_runtime)
749
+ discovery = discover_compatible_model(
750
+ rejected_model_id: rejection[:model],
751
+ allowed_model_ids: allowed_model_ids,
752
+ env: env,
753
+ timeout: timeout,
754
+ refresh: true
755
+ )
756
+ {
757
+ rejection: rejection,
758
+ discovery: discovery.to_h,
759
+ provider_runtime: replacement_runtime(provider_runtime, discovery, rejection),
760
+ message: model_rejection_recovery_message(rejection, discovery)
761
+ }
762
+ end
763
+
690
764
  def send_message(prompt:, **options)
691
765
  super
692
766
  ensure
@@ -750,6 +824,7 @@ module AgentHarness
750
824
 
751
825
  def error_patterns
752
826
  {
827
+ subscription_model_rejected: MODEL_REJECTION_PATTERNS,
753
828
  rate_limited: COMMON_ERROR_PATTERNS[:rate_limited],
754
829
  timeout: [
755
830
  /your access token could not be refreshed.*(?:timeout|timed.?out)/im,
@@ -1002,6 +1077,249 @@ module AgentHarness
1002
1077
 
1003
1078
  private
1004
1079
 
1080
+ def discover_compatible_model(rejected_model_id:, env:, timeout:, allowed_model_ids: nil, refresh: false)
1081
+ cache_key = model_rejection_cache_key(rejected_model_id, env, allowed_model_ids)
1082
+ cached = self.class::MODEL_REJECTION_CACHE[cache_key]
1083
+ if !refresh && cached && cached[:expires_at] > monotonic_now
1084
+ return cached[:discovery]
1085
+ end
1086
+
1087
+ discovery = fetch_model_list(
1088
+ rejected_model_id: rejected_model_id,
1089
+ allowed_model_ids: allowed_model_ids,
1090
+ env: env,
1091
+ timeout: timeout
1092
+ )
1093
+ cache_model_discovery(cache_key, discovery)
1094
+ discovery
1095
+ end
1096
+
1097
+ def fetch_model_list(rejected_model_id:, allowed_model_ids:, env:, timeout:)
1098
+ result = @executor.execute_interactive(
1099
+ [self.class.binary_name, "app-server", "--listen", "stdio://"],
1100
+ timeout: timeout,
1101
+ env: env
1102
+ ) { |stdin, stdout| exchange_model_list_requests(stdin, stdout) }
1103
+ return unavailable_model_discovery(:app_server_failed, stderr: result.stderr) unless result.success?
1104
+
1105
+ response = parse_app_server_response(result.stdout, 2)
1106
+ return unavailable_model_discovery(:model_list_missing_response) unless response
1107
+ return unavailable_model_discovery(:model_list_error, error: response["error"]) if response["error"]
1108
+
1109
+ entries = Array(response.dig("result", "data")).filter_map { |entry| normalize_model_entry(entry) }
1110
+ alternatives = entries.reject { |entry| entry[:id] == rejected_model_id }
1111
+ alternatives.select! { |entry| allowed_model_ids.include?(entry[:id]) } if allowed_model_ids
1112
+ recommended = alternatives.find { |entry| entry[:is_default] } || alternatives.first
1113
+ return unavailable_model_discovery(:no_compatible_model, models: entries) unless recommended
1114
+
1115
+ ModelDiscovery.new(
1116
+ status: :available,
1117
+ recommended_model_id: recommended[:id],
1118
+ models: entries,
1119
+ source: :codex_app_server_model_list,
1120
+ details: {default_model_id: recommended[:id]}
1121
+ )
1122
+ rescue TimeoutError
1123
+ unavailable_model_discovery(:app_server_timeout)
1124
+ rescue ArgumentError, JSON::ParserError
1125
+ unavailable_model_discovery(:model_list_unparseable)
1126
+ end
1127
+
1128
+ def exchange_model_list_requests(stdin, stdout)
1129
+ responses = +""
1130
+ write_app_server_request(stdin, MODEL_LIST_REQUESTS.first)
1131
+ initialized = read_app_server_response(stdout, responses, 1)
1132
+ raise ArgumentError, "app-server rejected initialize" if initialized["error"]
1133
+
1134
+ MODEL_LIST_REQUESTS.drop(1).each { |request| write_app_server_request(stdin, request) }
1135
+ read_app_server_response(stdout, responses, 2)
1136
+ responses
1137
+ end
1138
+
1139
+ def write_app_server_request(stdin, request)
1140
+ stdin.puts(JSON.generate(request))
1141
+ stdin.flush
1142
+ end
1143
+
1144
+ def read_app_server_response(stdout, responses, id)
1145
+ loop do
1146
+ line = stdout.gets
1147
+ raise JSON::ParserError, "app-server closed before response #{id}" unless line
1148
+
1149
+ responses << line
1150
+ message = JSON.parse(line)
1151
+ return message if message.is_a?(Hash) && message["id"] == id
1152
+ rescue JSON::ParserError
1153
+ raise if line.nil?
1154
+ end
1155
+ end
1156
+
1157
+ def parse_app_server_response(stdout, id)
1158
+ stdout.to_s.each_line.filter_map do |line|
1159
+ JSON.parse(line)
1160
+ rescue JSON::ParserError
1161
+ nil
1162
+ end.find { |message| message.is_a?(Hash) && message["id"] == id }
1163
+ end
1164
+
1165
+ def normalize_model_entry(entry)
1166
+ return unless entry.is_a?(Hash)
1167
+
1168
+ id = entry["id"] || entry["model"]
1169
+ return if id.to_s.strip.empty?
1170
+
1171
+ {
1172
+ id: id.to_s,
1173
+ model: (entry["model"] || id).to_s,
1174
+ display_name: entry["displayName"],
1175
+ hidden: entry["hidden"] == true,
1176
+ is_default: entry["isDefault"] == true,
1177
+ source: :codex_app_server_model_list
1178
+ }.compact
1179
+ end
1180
+
1181
+ def unavailable_model_discovery(reason, details = {})
1182
+ ModelDiscovery.new(
1183
+ status: :unavailable,
1184
+ recommended_model_id: nil,
1185
+ models: Array(details.delete(:models)),
1186
+ source: :codex_app_server_model_list,
1187
+ reason: reason,
1188
+ details: details.compact
1189
+ )
1190
+ end
1191
+
1192
+ def replacement_runtime(provider_runtime, discovery, rejection)
1193
+ return unless discovery.available?
1194
+ return if discovery.recommended_model_id == rejection[:model]
1195
+
1196
+ runtime = ProviderRuntime.wrap(provider_runtime)
1197
+ replacement = {model: discovery.recommended_model_id}
1198
+ return ProviderRuntime.new(**replacement) unless runtime
1199
+
1200
+ runtime.merge(replacement)
1201
+ end
1202
+
1203
+ def recovery_allowed_model_ids(provider_runtime)
1204
+ selected_model = ProviderRuntime.wrap(provider_runtime)&.model || @config.model
1205
+ selected_model ? [selected_model] : nil
1206
+ end
1207
+
1208
+ def model_rejection_recovery_message(rejection, discovery)
1209
+ rejected = rejection[:model] || "the selected model"
1210
+ unless discovery.available?
1211
+ return "Codex rejected #{rejected.inspect} for ChatGPT subscription auth, and model/list did not return a usable replacement (#{discovery.reason})."
1212
+ end
1213
+ if discovery.recommended_model_id == rejection[:model]
1214
+ return "Codex rejected #{rejected.inspect}, and model/list recommended the same model."
1215
+ end
1216
+
1217
+ "Codex rejected #{rejected.inspect}; retrying preflight with #{discovery.recommended_model_id.inspect} from model/list."
1218
+ end
1219
+
1220
+ def model_rejection_from_failure(failure, provider_runtime)
1221
+ output = [
1222
+ failure[:message],
1223
+ failure[:output],
1224
+ failure[:error],
1225
+ failure.dig(:metadata, :error)
1226
+ ].compact.join("\n")
1227
+ configured_model = ProviderRuntime.wrap(provider_runtime)&.model || @config.model
1228
+ self.class.classify_model_rejection(output, configured_model: configured_model)
1229
+ end
1230
+
1231
+ def classify_model_rejection(output, configured_model: nil)
1232
+ texts = extract_error_texts(output)
1233
+ texts.each do |text|
1234
+ MODEL_REJECTION_PATTERNS.each do |pattern|
1235
+ match = text.match(pattern)
1236
+ next unless match
1237
+
1238
+ return {
1239
+ type: :subscription_model_rejected,
1240
+ model: match[:model] || configured_model,
1241
+ configured_model: configured_model,
1242
+ auth_mode: :subscription,
1243
+ source: :codex_cli_error
1244
+ }.compact
1245
+ end
1246
+ end
1247
+
1248
+ nil
1249
+ end
1250
+
1251
+ def extract_error_texts(value)
1252
+ case value
1253
+ when Hash
1254
+ value.values.flat_map { |item| extract_error_texts(item) }
1255
+ when Array
1256
+ value.flat_map { |item| extract_error_texts(item) }
1257
+ else
1258
+ text = value.to_s
1259
+ nested = parse_nested_json_text(text)
1260
+ [text] + nested
1261
+ end
1262
+ end
1263
+
1264
+ def parse_nested_json_text(text)
1265
+ parsed = JSON.parse(text)
1266
+ return extract_error_texts(parsed) if parsed.is_a?(Hash) || parsed.is_a?(Array)
1267
+
1268
+ []
1269
+ rescue JSON::ParserError, TypeError
1270
+ text.scan(/\{.*?\}/m).flat_map do |candidate|
1271
+ JSON.parse(candidate).then { |parsed| extract_error_texts(parsed) }
1272
+ rescue JSON::ParserError
1273
+ []
1274
+ end
1275
+ end
1276
+
1277
+ def record_model_rejection(rejection, env:)
1278
+ cache_model_discovery(
1279
+ model_rejection_cache_key(rejection[:model], env),
1280
+ ModelDiscovery.new(
1281
+ status: :unavailable,
1282
+ models: [],
1283
+ source: :codex_cli_error,
1284
+ reason: :subscription_model_rejected,
1285
+ details: rejection
1286
+ )
1287
+ )
1288
+ end
1289
+
1290
+ def cache_model_discovery(cache_key, discovery)
1291
+ cache = self.class::MODEL_REJECTION_CACHE
1292
+ cache.delete(cache_key)
1293
+ cache[cache_key] = {
1294
+ discovery: discovery,
1295
+ expires_at: monotonic_now + MODEL_REJECTION_CACHE_TTL
1296
+ }
1297
+ cache.shift while cache.size > MODEL_REJECTION_CACHE_LIMIT
1298
+ end
1299
+
1300
+ def model_rejection_cache_key(model, env, allowed_model_ids = nil)
1301
+ [
1302
+ :codex,
1303
+ codex_cli_version(env: env, timeout: 2)&.to_s || "unknown",
1304
+ account_identity(env),
1305
+ model.to_s,
1306
+ Array(allowed_model_ids).sort
1307
+ ]
1308
+ end
1309
+
1310
+ def account_identity(env)
1311
+ credentials_path = codex_config_path_for_env(env)
1312
+ [
1313
+ env_fetch(env, "CODEX_HOME"),
1314
+ env_fetch(env, "HOME"),
1315
+ credentials_path
1316
+ ].compact.join("|")
1317
+ end
1318
+
1319
+ def monotonic_now
1320
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1321
+ end
1322
+
1005
1323
  def auth_status_for_env(env)
1006
1324
  api_key = env_fetch(env, "OPENAI_API_KEY")
1007
1325
  # Fall back to process ENV when the provided env hash does not override auth keys
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.36.24"
4
+ VERSION = "0.37.0"
5
5
  end
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.36.24
4
+ version: 0.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan