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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/lib/agent_harness/command_executor.rb +46 -0
- data/lib/agent_harness/docker_command_executor.rb +10 -0
- data/lib/agent_harness/provider_health_check.rb +83 -4
- data/lib/agent_harness/providers/adapter.rb +4 -2
- data/lib/agent_harness/providers/codex.rb +318 -0
- data/lib/agent_harness/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 045e59747b5b64528739836b362b75b21df461fc7955d47506b410a77aabdc01
|
|
4
|
+
data.tar.gz: 848b47345c5c93bad14bc892a2c8fe9b8c779e81e924e0ee381204581cd66ec9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5df080c3c51a1992ed0a51d97f755e4d2c761a5903f67aaf72635a3085eb0a8d4e0c87d4765ddad765234f660be2d814b7cebee0b83be3633adce170690d4d9
|
|
7
|
+
data.tar.gz: 5d8c6ae0a2537011966fc607654a3d2807be8bceb7d1a730415e0e5ddee3a9faa54dd4e683d13c35cc3def5d6c3b9ab97c4293d00ef8ae174692369750252a86
|
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
|