aidp 0.23.0 → 0.24.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/aidp/cli.rb +3 -0
  3. data/lib/aidp/execute/work_loop_runner.rb +252 -45
  4. data/lib/aidp/execute/work_loop_unit_scheduler.rb +27 -2
  5. data/lib/aidp/harness/condition_detector.rb +42 -8
  6. data/lib/aidp/harness/config_manager.rb +7 -0
  7. data/lib/aidp/harness/config_schema.rb +25 -0
  8. data/lib/aidp/harness/configuration.rb +69 -6
  9. data/lib/aidp/harness/error_handler.rb +117 -44
  10. data/lib/aidp/harness/provider_manager.rb +64 -0
  11. data/lib/aidp/harness/provider_metrics.rb +138 -0
  12. data/lib/aidp/harness/runner.rb +90 -29
  13. data/lib/aidp/harness/simple_user_interface.rb +4 -0
  14. data/lib/aidp/harness/state/ui_state.rb +0 -10
  15. data/lib/aidp/harness/state_manager.rb +1 -15
  16. data/lib/aidp/harness/test_runner.rb +39 -2
  17. data/lib/aidp/logger.rb +34 -4
  18. data/lib/aidp/providers/adapter.rb +241 -0
  19. data/lib/aidp/providers/anthropic.rb +75 -7
  20. data/lib/aidp/providers/base.rb +29 -1
  21. data/lib/aidp/providers/capability_registry.rb +205 -0
  22. data/lib/aidp/providers/codex.rb +14 -0
  23. data/lib/aidp/providers/error_taxonomy.rb +195 -0
  24. data/lib/aidp/providers/gemini.rb +3 -2
  25. data/lib/aidp/setup/provider_registry.rb +107 -0
  26. data/lib/aidp/setup/wizard.rb +115 -31
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +263 -23
  29. data/lib/aidp/watch/repository_client.rb +4 -4
  30. data/lib/aidp/watch/runner.rb +37 -5
  31. data/lib/aidp/workflows/guided_agent.rb +53 -0
  32. data/lib/aidp/worktree.rb +67 -10
  33. data/templates/work_loop/decide_whats_next.md +21 -0
  34. data/templates/work_loop/diagnose_failures.md +21 -0
  35. metadata +10 -3
  36. /data/{bin → exe}/aidp +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "timeout"
4
4
  require "json"
5
- require_relative "config_manager"
5
+ require_relative "configuration"
6
6
  require_relative "state_manager"
7
7
  require_relative "condition_detector"
8
8
  require_relative "provider_manager"
@@ -44,27 +44,31 @@ module Aidp
44
44
  @current_provider = nil
45
45
  @user_input = options[:user_input] || {} # Include user input from workflow selection
46
46
  @execution_log = []
47
+ @last_error = nil
47
48
  @prompt = options[:prompt] || TTY::Prompt.new
48
49
 
49
50
  # Store workflow configuration
50
51
  @selected_steps = options[:selected_steps]
51
52
  @workflow_type = options[:workflow_type]
53
+ @non_interactive = options[:non_interactive] || (@workflow_type == :watch_mode)
52
54
 
53
55
  # Initialize components
54
- @config_manager = ConfigManager.new(project_dir)
56
+ @configuration = Configuration.new(project_dir)
55
57
  @state_manager = StateManager.new(project_dir, @mode)
56
- @provider_manager = ProviderManager.new(@config_manager, prompt: @prompt)
58
+ @provider_manager = ProviderManager.new(@configuration, prompt: @prompt)
57
59
 
58
60
  # Use ZFC-enabled condition detector
59
61
  # ZfcConditionDetector will create its own ProviderFactory if needed
60
62
  # Falls back to legacy pattern matching when ZFC is disabled
61
63
  require_relative "zfc_condition_detector"
62
- @condition_detector = ZfcConditionDetector.new(@config_manager)
64
+ @condition_detector = ZfcConditionDetector.new(@configuration)
63
65
 
64
66
  @user_interface = SimpleUserInterface.new
65
- @error_handler = ErrorHandler.new(@provider_manager, @config_manager)
67
+ @error_handler = ErrorHandler.new(@provider_manager, @configuration)
66
68
  @status_display = StatusDisplay.new
67
69
  @completion_checker = CompletionChecker.new(@project_dir, @workflow_type)
70
+ @failure_reason = nil
71
+ @failure_metadata = nil
68
72
  end
69
73
 
70
74
  # Main execution method - runs the harness loop
@@ -114,27 +118,55 @@ module Aidp
114
118
  display_message(completion_status[:summary], type: :info)
115
119
 
116
120
  # Ask user if they want to continue anyway
117
- if @user_interface.get_confirmation("Continue anyway? This may indicate issues that should be addressed.", default: false)
118
- @state = STATES[:completed]
119
- log_execution("Harness completed with user override")
121
+ if confirmation_prompt_allowed?
122
+ if @user_interface.get_confirmation("Continue anyway? This may indicate issues that should be addressed.", default: false)
123
+ @state = STATES[:completed]
124
+ log_execution("Harness completed with user override")
125
+ else
126
+ mark_completion_failure(completion_status)
127
+ @state = STATES[:error]
128
+ log_execution("Harness stopped due to unmet completion criteria")
129
+ end
120
130
  else
131
+ display_message("⚠️ Non-interactive mode: cannot override failed completion criteria. Stopping run.", type: :warning)
132
+ mark_completion_failure(completion_status)
121
133
  @state = STATES[:error]
122
- log_execution("Harness stopped due to unmet completion criteria")
134
+ log_execution("Harness stopped due to unmet completion criteria in non-interactive mode")
123
135
  end
124
136
  end
125
137
  end
126
138
  rescue => e
127
139
  @state = STATES[:error]
128
- log_execution("Harness error: #{e.message}", {error: e.class.name})
140
+ @last_error = e
141
+ log_execution("Harness error: #{e.message}", {error: e.class.name, backtrace: e.backtrace&.first(5)})
129
142
  handle_error(e)
130
143
  ensure
131
- # Save state before exiting
132
- save_state
133
- cleanup
144
+ # Save state before exiting - protect against exceptions during cleanup
145
+ begin
146
+ save_state
147
+ rescue => e
148
+ # Don't let state save failures kill the whole run or prevent cleanup
149
+ Aidp.logger.error("harness", "Failed to save state during cleanup: #{e.message}", error: e.class.name)
150
+ @last_error ||= e # Only set if no previous error
151
+ end
152
+
153
+ begin
154
+ cleanup
155
+ rescue => e
156
+ # Don't let cleanup failures propagate
157
+ Aidp.logger.error("harness", "Failed during cleanup: #{e.message}", error: e.class.name)
158
+ end
134
159
  end
135
160
 
136
161
  result = {status: @state, message: get_completion_message}
162
+ result[:reason] = @failure_reason if @failure_reason
163
+ result[:failure_metadata] = @failure_metadata if @failure_metadata
137
164
  result[:clarification_questions] = @clarification_questions if @clarification_questions
165
+ if @last_error
166
+ result[:error] = @last_error.message
167
+ result[:error_class] = @last_error.class.name
168
+ result[:backtrace] = @last_error.backtrace&.first(10)
169
+ end
138
170
  result
139
171
  end
140
172
 
@@ -183,9 +215,9 @@ module Aidp
183
215
  {
184
216
  harness: status,
185
217
  configuration: {
186
- default_provider: @config_manager.default_provider,
187
- fallback_providers: @config_manager.fallback_providers,
188
- max_retries: @config_manager.harness_config[:max_retries]
218
+ default_provider: @configuration.default_provider,
219
+ fallback_providers: @configuration.fallback_providers,
220
+ max_retries: @configuration.harness_config[:max_retries]
189
221
  },
190
222
  provider_manager: @provider_manager.status,
191
223
  error_stats: @error_handler.error_stats
@@ -281,15 +313,30 @@ module Aidp
281
313
  log_execution("User feedback collected", {responses: user_responses.keys})
282
314
  end
283
315
 
284
- def handle_rate_limit(_result)
316
+ def handle_rate_limit(result)
285
317
  @state = STATES[:waiting_for_rate_limit]
286
318
  log_execution("Rate limit detected, switching provider")
287
319
 
320
+ rate_limit_info = nil
321
+ if @condition_detector.respond_to?(:extract_rate_limit_info)
322
+ rate_limit_info = @condition_detector.extract_rate_limit_info(result, @current_provider)
323
+ end
324
+ reset_time = rate_limit_info && rate_limit_info[:reset_time]
325
+
288
326
  # Mark current provider as rate limited
289
- @provider_manager.mark_rate_limited(@current_provider)
327
+ @provider_manager.mark_rate_limited(@current_provider, reset_time)
328
+
329
+ # Provider manager might already have switched upstream (e.g., during CLI execution)
330
+ manager_current = @provider_manager.current_provider
331
+ if manager_current && manager_current != @current_provider
332
+ @current_provider = manager_current
333
+ @state = STATES[:running]
334
+ log_execution("Provider already switched upstream", new_provider: manager_current)
335
+ return
336
+ end
290
337
 
291
- # Switch to next provider
292
- next_provider = @provider_manager.switch_provider
338
+ # Switch to next provider explicitly when still on the rate-limited provider
339
+ next_provider = @provider_manager.switch_provider("rate_limit", previous_provider: @current_provider)
293
340
  @current_provider = next_provider
294
341
 
295
342
  if next_provider
@@ -313,6 +360,10 @@ module Aidp
313
360
  end
314
361
  end
315
362
 
363
+ def confirmation_prompt_allowed?
364
+ !@non_interactive
365
+ end
366
+
316
367
  def sleep_until_reset(reset_time)
317
368
  while Time.now < reset_time && @state == STATES[:waiting_for_rate_limit]
318
369
  remaining = reset_time - Time.now
@@ -374,20 +425,14 @@ module Aidp
374
425
  end
375
426
 
376
427
  def save_state
377
- # Save harness-specific state
428
+ # Save harness-specific state (execution_log removed to prevent unbounded growth)
378
429
  @state_manager.save_state({
379
430
  state: @state,
380
431
  current_step: @current_step,
381
432
  current_provider: @current_provider,
382
433
  user_input: @user_input,
383
- execution_log: @execution_log,
384
434
  last_saved: Time.now
385
435
  })
386
-
387
- # Also save execution log entries to state manager
388
- @execution_log.each do |entry|
389
- @state_manager.add_execution_log(entry)
390
- end
391
436
  end
392
437
 
393
438
  def handle_error(error)
@@ -400,6 +445,7 @@ module Aidp
400
445
  end
401
446
 
402
447
  def log_execution(message, data = {})
448
+ # Keep in-memory log for runtime diagnostics (not persisted)
403
449
  log_entry = {
404
450
  timestamp: Time.now,
405
451
  message: message,
@@ -408,7 +454,13 @@ module Aidp
408
454
  }
409
455
  @execution_log << log_entry
410
456
 
411
- # Also log to standard logging if available
457
+ # Log to persistent logger instead of state file
458
+ Aidp.logger.info("harness_execution", message,
459
+ state: @state,
460
+ step: @current_step,
461
+ **data.slice(:error, :error_class, :criteria, :all_complete, :summary).compact)
462
+
463
+ # Also log to standard output in debug mode
412
464
  puts "[#{Time.now.strftime("%H:%M:%S")}] #{message}" if ENV["AIDP_DEBUG"] == "1"
413
465
  end
414
466
 
@@ -419,12 +471,21 @@ module Aidp
419
471
  when STATES[:stopped]
420
472
  "Harness stopped by user."
421
473
  when STATES[:error]
422
- "Harness encountered an error and stopped."
474
+ if @last_error
475
+ "Harness encountered an error and stopped: #{@last_error.class.name}: #{@last_error.message}"
476
+ else
477
+ "Harness encountered an error and stopped."
478
+ end
423
479
  else
424
480
  "Harness finished in state: #{@state}"
425
481
  end
426
482
  end
427
483
 
484
+ def mark_completion_failure(completion_status)
485
+ @failure_reason = :completion_criteria
486
+ @failure_metadata = completion_status
487
+ end
488
+
428
489
  private
429
490
  end
430
491
  end
@@ -26,6 +26,10 @@ module Aidp
26
26
  responses
27
27
  end
28
28
 
29
+ def get_confirmation(message, default: true)
30
+ @prompt.yes?(message, default: default)
31
+ end
32
+
29
33
  private
30
34
 
31
35
  def show_context(context)
@@ -19,16 +19,6 @@ module Aidp
19
19
  update_state(user_input: current_input)
20
20
  end
21
21
 
22
- def execution_log
23
- state[:execution_log] || []
24
- end
25
-
26
- def add_execution_log(entry)
27
- current_log = execution_log
28
- current_log << entry
29
- update_state(execution_log: current_log)
30
- end
31
-
32
22
  def current_step
33
23
  state[:current_step]
34
24
  end
@@ -129,20 +129,6 @@ module Aidp
129
129
  update_state(user_input: current_input, last_updated: Time.now)
130
130
  end
131
131
 
132
- # Get execution log
133
- def execution_log
134
- state = load_state
135
- return [] unless state
136
- state[:execution_log] || []
137
- end
138
-
139
- # Add to execution log
140
- def add_execution_log(entry)
141
- current_log = execution_log
142
- current_log << entry
143
- update_state(execution_log: current_log, last_updated: Time.now)
144
- end
145
-
146
132
  # Get provider state
147
133
  def provider_state
148
134
  state = load_state
@@ -615,7 +601,7 @@ module Aidp
615
601
  yield
616
602
  end
617
603
  end
618
- rescue Aidp::Concurrency::MaxAttemptsExceededError
604
+ rescue Aidp::Concurrency::MaxAttemptsError
619
605
  raise "Could not acquire state lock within timeout"
620
606
  ensure
621
607
  # Clean up lock file
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../tooling_detector"
4
5
 
5
6
  module Aidp
6
7
  module Harness
@@ -15,7 +16,7 @@ module Aidp
15
16
  # Run all configured tests
16
17
  # Returns: { success: boolean, output: string, failures: array }
17
18
  def run_tests
18
- test_commands = @config.test_commands || []
19
+ test_commands = resolved_test_commands
19
20
  return {success: true, output: "", failures: []} if test_commands.empty?
20
21
 
21
22
  results = test_commands.map { |cmd| execute_command(cmd, "test") }
@@ -25,7 +26,7 @@ module Aidp
25
26
  # Run all configured linters
26
27
  # Returns: { success: boolean, output: string, failures: array }
27
28
  def run_linters
28
- lint_commands = @config.lint_commands || []
29
+ lint_commands = resolved_lint_commands
29
30
  return {success: true, output: "", failures: []} if lint_commands.empty?
30
31
 
31
32
  results = lint_commands.map { |cmd| execute_command(cmd, "linter") }
@@ -78,6 +79,42 @@ module Aidp
78
79
 
79
80
  output.join("\n")
80
81
  end
82
+
83
+ def resolved_test_commands
84
+ explicit = Array(@config.test_commands).compact.map(&:strip).reject(&:empty?)
85
+ return explicit unless explicit.empty?
86
+
87
+ detected = detected_tooling.test_commands
88
+ log_fallback(:tests, detected) unless detected.empty?
89
+ detected
90
+ end
91
+
92
+ def resolved_lint_commands
93
+ explicit = Array(@config.lint_commands).compact.map(&:strip).reject(&:empty?)
94
+ return explicit unless explicit.empty?
95
+
96
+ detected = detected_tooling.lint_commands
97
+ log_fallback(:linters, detected) unless detected.empty?
98
+ detected
99
+ end
100
+
101
+ def detected_tooling
102
+ @detected_tooling ||= Aidp::ToolingDetector.detect(@project_dir)
103
+ rescue => e
104
+ Aidp.log_warn("test_runner", "tooling_detection_failed", error: e.message)
105
+ Aidp::ToolingDetector::Result.new(test_commands: [], lint_commands: [])
106
+ end
107
+
108
+ def log_fallback(type, commands)
109
+ Aidp.log_info(
110
+ "test_runner",
111
+ "auto_detected_commands",
112
+ category: type,
113
+ commands: commands
114
+ )
115
+ rescue NameError
116
+ # Logging infrastructure not available in some tests
117
+ end
81
118
  end
82
119
  end
83
120
  end
data/lib/aidp/logger.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "logger"
4
4
  require "json"
5
5
  require "fileutils"
6
+ require "pathname"
6
7
 
7
8
  module Aidp
8
9
  # Unified structured logger for all AIDP operations
@@ -84,12 +85,30 @@ module Aidp
84
85
  private
85
86
 
86
87
  def determine_log_level
87
- # Priority: ENV > config > default
88
- level_str = ENV["AIDP_LOG_LEVEL"] || @config[:level] || "info"
89
- level_sym = level_str.to_sym
88
+ # Priority: explicit env override > DEBUG flags > config > default
89
+ level_str = if ENV["AIDP_LOG_LEVEL"]
90
+ ENV["AIDP_LOG_LEVEL"]
91
+ elsif debug_env_enabled?
92
+ "debug"
93
+ elsif @config[:level]
94
+ @config[:level]
95
+ else
96
+ "info"
97
+ end
98
+ level_sym = level_str.to_s.to_sym
90
99
  LEVELS.key?(level_sym) ? level_sym : :info
91
100
  end
92
101
 
102
+ def debug_env_enabled?
103
+ raw = ENV["AIDP_DEBUG"] || ENV["DEBUG"]
104
+ return false if raw.nil?
105
+
106
+ normalized = raw.to_s.strip.downcase
107
+ return true if %w[true on yes debug].include?(normalized)
108
+
109
+ /\A\d+\z/.match?(normalized) ? normalized.to_i.positive? : false
110
+ end
111
+
93
112
  def should_log?(level)
94
113
  LEVELS[level] >= LEVELS[@level]
95
114
  end
@@ -106,7 +125,7 @@ module Aidp
106
125
  end
107
126
 
108
127
  def setup_logger
109
- info_path = File.join(@project_dir, INFO_LOG)
128
+ info_path = determine_log_file_path
110
129
  @logger = create_logger(info_path)
111
130
  # Emit instrumentation after logger is available (avoid recursive Aidp.log_* calls during bootstrap)
112
131
  return unless @instrument_internal
@@ -181,6 +200,17 @@ module Aidp
181
200
  JSON.generate(entry)
182
201
  end
183
202
 
203
+ def determine_log_file_path
204
+ custom = (ENV["AIDP_LOG_FILE"] || @config[:file]).to_s.strip
205
+ relative_path = custom.empty? ? INFO_LOG : custom
206
+
207
+ if Pathname.new(relative_path).absolute?
208
+ relative_path
209
+ else
210
+ File.join(@project_dir, relative_path)
211
+ end
212
+ end
213
+
184
214
  # Redaction patterns for common secrets
185
215
  REDACTION_PATTERNS = [
186
216
  # API keys and tokens (with capture groups)
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Providers
5
+ # ProviderAdapter defines the standardized interface that all provider implementations
6
+ # must conform to. This ensures consistent behavior across different AI model providers
7
+ # while allowing for provider-specific implementations.
8
+ #
9
+ # Design Philosophy:
10
+ # - Adapters are stateless; delegate throttling, retries, and escalation to coordinator
11
+ # - Store provider-specific regex matchers adjacent to adapters for maintainability
12
+ # - Single semantic flags map to provider-specific equivalents
13
+ #
14
+ # @see https://github.com/viamin/aidp/issues/243
15
+ module Adapter
16
+ # Core interface methods that all providers must implement
17
+
18
+ # Provider identifier (e.g., "anthropic", "cursor", "gemini")
19
+ # @return [String] unique lowercase identifier for this provider
20
+ def name
21
+ raise NotImplementedError, "#{self.class} must implement #name"
22
+ end
23
+
24
+ # Human-friendly display name for UI
25
+ # @return [String] display name (e.g., "Anthropic Claude", "Cursor AI")
26
+ def display_name
27
+ name
28
+ end
29
+
30
+ # Send a message to the provider and get a response
31
+ # @param prompt [String] the prompt to send
32
+ # @param session [String, nil] optional session identifier for context
33
+ # @param options [Hash] additional options for the request
34
+ # @return [Hash, String] provider response
35
+ def send_message(prompt:, session: nil, **options)
36
+ raise NotImplementedError, "#{self.class} must implement #send_message"
37
+ end
38
+
39
+ # Capability declaration methods
40
+
41
+ # Check if the provider supports Model Context Protocol
42
+ # @return [Boolean] true if MCP is supported
43
+ def supports_mcp?
44
+ false
45
+ end
46
+
47
+ # Fetch MCP servers configured for this provider
48
+ # @return [Array<Hash>] array of MCP server configurations
49
+ def fetch_mcp_servers
50
+ []
51
+ end
52
+
53
+ # Check if the provider is available on this system
54
+ # @return [Boolean] true if provider CLI or API is accessible
55
+ def available?
56
+ true
57
+ end
58
+
59
+ # Declare provider capabilities
60
+ # @return [Hash] capabilities hash with feature flags
61
+ # @example
62
+ # {
63
+ # reasoning_tiers: ["mini", "standard", "thinking"],
64
+ # context_window: 200_000,
65
+ # supports_json_mode: true,
66
+ # supports_tool_use: true,
67
+ # supports_vision: false,
68
+ # supports_file_upload: true,
69
+ # streaming: true
70
+ # }
71
+ def capabilities
72
+ {
73
+ reasoning_tiers: [],
74
+ context_window: 100_000,
75
+ supports_json_mode: false,
76
+ supports_tool_use: false,
77
+ supports_vision: false,
78
+ supports_file_upload: false,
79
+ streaming: false
80
+ }
81
+ end
82
+
83
+ # Dangerous permissions abstraction
84
+
85
+ # Check if the provider supports dangerous/elevated permissions mode
86
+ # @return [Boolean] true if dangerous mode is supported
87
+ def supports_dangerous_mode?
88
+ false
89
+ end
90
+
91
+ # Get the provider-specific flag(s) for enabling dangerous mode
92
+ # Maps the semantic `dangerous: true` flag to provider-specific equivalents
93
+ # @return [Array<String>] provider-specific CLI flags
94
+ # @example Anthropic
95
+ # ["--dangerously-skip-permissions"]
96
+ # @example Gemini (hypothetical)
97
+ # ["--yolo"]
98
+ def dangerous_mode_flags
99
+ []
100
+ end
101
+
102
+ # Check if dangerous mode is currently enabled
103
+ # @return [Boolean] true if dangerous mode is active
104
+ def dangerous_mode_enabled?
105
+ @dangerous_mode_enabled ||= false
106
+ end
107
+
108
+ # Enable or disable dangerous mode
109
+ # @param enabled [Boolean] whether to enable dangerous mode
110
+ # @return [void]
111
+ def dangerous_mode=(enabled)
112
+ @dangerous_mode_enabled = enabled
113
+ end
114
+
115
+ # Error classification and handling
116
+
117
+ # Get error classification regex patterns for this provider
118
+ # @return [Hash<Symbol, Array<Regexp>>] mapping of error categories to regex patterns
119
+ # @example
120
+ # {
121
+ # rate_limited: [/rate.?limit/i, /quota.*exceeded/i],
122
+ # auth_expired: [/authentication.*failed/i, /invalid.*api.*key/i],
123
+ # quota_exceeded: [/quota.*exceeded/i, /usage.*limit/i],
124
+ # transient: [/timeout/i, /connection.*reset/i, /temporary.*error/i],
125
+ # permanent: [/invalid.*model/i, /unsupported.*operation/i]
126
+ # }
127
+ def error_patterns
128
+ {}
129
+ end
130
+
131
+ # Classify an error into the standardized error taxonomy
132
+ # @param error [StandardError] the error to classify
133
+ # @return [Symbol] error category (:rate_limited, :auth_expired, :quota_exceeded, :transient, :permanent)
134
+ def classify_error(error)
135
+ message = error.message.to_s
136
+
137
+ # First check provider-specific patterns
138
+ error_patterns.each do |category, patterns|
139
+ patterns.each do |pattern|
140
+ return category if message.match?(pattern)
141
+ end
142
+ end
143
+
144
+ # Fall back to ErrorTaxonomy for classification
145
+ require_relative "error_taxonomy"
146
+ Aidp::Providers::ErrorTaxonomy.classify_message(message)
147
+ end
148
+
149
+ # Get normalized error metadata
150
+ # @param error [StandardError] the error to process
151
+ # @return [Hash] normalized error information
152
+ def error_metadata(error)
153
+ {
154
+ provider: name,
155
+ error_category: classify_error(error),
156
+ error_class: error.class.name,
157
+ message: redact_secrets(error.message),
158
+ timestamp: Time.now.iso8601,
159
+ retryable: retryable_error?(error)
160
+ }
161
+ end
162
+
163
+ # Check if an error is retryable
164
+ # @param error [StandardError] the error to check
165
+ # @return [Boolean] true if the error should be retried
166
+ def retryable_error?(error)
167
+ category = classify_error(error)
168
+ [:transient].include?(category)
169
+ end
170
+
171
+ # Logging and metrics
172
+
173
+ # Get logging metadata for this provider
174
+ # @return [Hash] metadata for structured logging
175
+ def logging_metadata
176
+ {
177
+ provider: name,
178
+ display_name: display_name,
179
+ supports_mcp: supports_mcp?,
180
+ available: available?,
181
+ dangerous_mode: dangerous_mode_enabled?
182
+ }
183
+ end
184
+
185
+ # Redact secrets from log messages
186
+ # @param message [String] message potentially containing secrets
187
+ # @return [String] message with secrets redacted
188
+ def redact_secrets(message)
189
+ # Redact common secret patterns
190
+ message = message.gsub(/api[_-]?key[:\s=]+[^\s&]+/i, "api_key=[REDACTED]")
191
+ message = message.gsub(/token[:\s=]+[^\s&]+/i, "token=[REDACTED]")
192
+ message = message.gsub(/password[:\s=]+[^\s&]+/i, "password=[REDACTED]")
193
+ message = message.gsub(/bearer\s+[^\s&]+/i, "bearer [REDACTED]")
194
+ message.gsub(/sk-[a-zA-Z0-9_-]{20,}/i, "sk-[REDACTED]")
195
+ end
196
+
197
+ # Configuration validation
198
+
199
+ # Validate provider configuration
200
+ # @param config [Hash] configuration to validate
201
+ # @return [Hash] validation result with :valid, :errors, :warnings keys
202
+ def validate_config(config)
203
+ errors = []
204
+ warnings = []
205
+
206
+ # Validate required fields
207
+ unless config[:type]
208
+ errors << "Provider type is required"
209
+ end
210
+
211
+ unless ["usage_based", "subscription", "passthrough"].include?(config[:type])
212
+ errors << "Provider type must be one of: usage_based, subscription, passthrough"
213
+ end
214
+
215
+ # Validate models if present
216
+ if config[:models] && !config[:models].is_a?(Array)
217
+ errors << "Models must be an array"
218
+ end
219
+
220
+ {
221
+ valid: errors.empty?,
222
+ errors: errors,
223
+ warnings: warnings
224
+ }
225
+ end
226
+
227
+ # Provider health and status
228
+
229
+ # Check provider health
230
+ # @return [Hash] health status information
231
+ def health_status
232
+ {
233
+ provider: name,
234
+ available: available?,
235
+ healthy: available?,
236
+ timestamp: Time.now.iso8601
237
+ }
238
+ end
239
+ end
240
+ end
241
+ end