aidp 0.29.0 → 0.30.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: 00cbc8656c3ce1550f5246837062879c70a0206fa62c00a7bef6cd20979b9be5
4
- data.tar.gz: 353616de5a90d4cf722673040d6e7fba09d60ff63618a3c135688202694e3075
3
+ metadata.gz: dcf4f30f93195b349c527dd5531e056ad566a34be1ed079ced4b5002f07a93f5
4
+ data.tar.gz: 22a7ef871ab691e0072a1bc8f1254784e1605f46d3edfc9585e91c3a21c5180f
5
5
  SHA512:
6
- metadata.gz: 65109acb0af57d4fefccbefa626f928cec5faaa3eedb5dfe4c0141521e03d95c8f7ca2dac8455baebdf6c14d0cbcd6cd81557a0bf4b2f0cdadb62547f60b414d
7
- data.tar.gz: ea289ac3406776ccc1c602795fe6fa0a54c09fe8b3729b7e0f3b0151ba2782613cc2fee68b1111f9f6b81c78efeb7d31e77ab233ea49fba48e6d5585505e60f8
6
+ metadata.gz: c5630eb8255464816006bb091b02666b29ac58b4a1e3e9136c17f79dff54762d6ebe16a4ec45df3f26c22047959121db04ef4e94904be5a4fb2e03320c6a6113
7
+ data.tar.gz: 5bdae7edfb7aad8ef03dd2274c4a8095bd927097cbd882a28856f0f753b46c4f1f0d8f756801c6df239bd13540355cbf9e53805b3bc815e5ac1cfc407d61c1c9
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aidp
7
+ module Harness
8
+ # Manages a dynamic cache of deprecated models detected at runtime
9
+ # When deprecation errors are detected from provider APIs, models are
10
+ # added to this cache with metadata (replacement, detected date, etc.)
11
+ class DeprecationCache
12
+ class CacheError < StandardError; end
13
+
14
+ attr_reader :cache_path
15
+
16
+ def initialize(cache_path: nil, root_dir: nil)
17
+ @root_dir = root_dir || safe_root_dir
18
+ @cache_path = cache_path || default_cache_path
19
+ @cache_data = nil
20
+ ensure_cache_directory
21
+ end
22
+
23
+ # Add a deprecated model to the cache
24
+ # @param provider [String] Provider name (e.g., "anthropic")
25
+ # @param model_id [String] Deprecated model ID
26
+ # @param replacement [String, nil] Replacement model ID (if known)
27
+ # @param reason [String, nil] Deprecation reason/message
28
+ def add_deprecated_model(provider:, model_id:, replacement: nil, reason: nil)
29
+ load_cache unless @cache_data
30
+
31
+ @cache_data["providers"][provider] ||= {}
32
+ @cache_data["providers"][provider][model_id] = {
33
+ "deprecated_at" => Time.now.iso8601,
34
+ "replacement" => replacement,
35
+ "reason" => reason
36
+ }.compact
37
+
38
+ save_cache
39
+ Aidp.log_info("deprecation_cache", "Added deprecated model",
40
+ provider: provider, model: model_id, replacement: replacement)
41
+ end
42
+
43
+ # Check if a model is deprecated
44
+ # @param provider [String] Provider name
45
+ # @param model_id [String] Model ID to check
46
+ # @return [Boolean]
47
+ def deprecated?(provider:, model_id:)
48
+ load_cache unless @cache_data
49
+ @cache_data.dig("providers", provider, model_id) != nil
50
+ end
51
+
52
+ # Get replacement model for a deprecated model
53
+ # @param provider [String] Provider name
54
+ # @param model_id [String] Deprecated model ID
55
+ # @return [String, nil] Replacement model ID or nil
56
+ def replacement_for(provider:, model_id:)
57
+ load_cache unless @cache_data
58
+ @cache_data.dig("providers", provider, model_id, "replacement")
59
+ end
60
+
61
+ # Get all deprecated models for a provider
62
+ # @param provider [String] Provider name
63
+ # @return [Array<String>] List of deprecated model IDs
64
+ def deprecated_models(provider:)
65
+ load_cache unless @cache_data
66
+ (@cache_data.dig("providers", provider) || {}).keys
67
+ end
68
+
69
+ # Remove a model from the deprecated cache
70
+ # Useful if a model comes back or was incorrectly marked
71
+ # @param provider [String] Provider name
72
+ # @param model_id [String] Model ID to remove
73
+ def remove_deprecated_model(provider:, model_id:)
74
+ load_cache unless @cache_data
75
+ return unless @cache_data.dig("providers", provider, model_id)
76
+
77
+ @cache_data["providers"][provider].delete(model_id)
78
+ @cache_data["providers"].delete(provider) if @cache_data["providers"][provider].empty?
79
+
80
+ save_cache
81
+ Aidp.log_info("deprecation_cache", "Removed deprecated model",
82
+ provider: provider, model: model_id)
83
+ end
84
+
85
+ # Get full deprecation info for a model
86
+ # @param provider [String] Provider name
87
+ # @param model_id [String] Model ID
88
+ # @return [Hash, nil] Deprecation metadata or nil
89
+ def info(provider:, model_id:)
90
+ load_cache unless @cache_data
91
+ @cache_data.dig("providers", provider, model_id)
92
+ end
93
+
94
+ # Clear all cached deprecations
95
+ def clear!
96
+ @cache_data = default_cache_structure
97
+ save_cache
98
+ Aidp.log_info("deprecation_cache", "Cleared all deprecations")
99
+ end
100
+
101
+ # Get cache statistics
102
+ # @return [Hash] Statistics about cached deprecations
103
+ def stats
104
+ load_cache unless @cache_data
105
+ {
106
+ providers: @cache_data["providers"].keys.sort,
107
+ total_deprecated: @cache_data["providers"].sum { |_, models| models.size },
108
+ by_provider: @cache_data["providers"].transform_values(&:size)
109
+ }
110
+ end
111
+
112
+ private
113
+
114
+ # Get a safe root directory for the cache
115
+ # Uses Dir.pwd if writable, otherwise falls back to tmpdir
116
+ def safe_root_dir
117
+ pwd = Dir.pwd
118
+ aidp_dir = File.join(pwd, ".aidp")
119
+
120
+ # Try to create the directory to test writability
121
+ begin
122
+ FileUtils.mkdir_p(aidp_dir) unless File.exist?(aidp_dir)
123
+ pwd
124
+ rescue Errno::EACCES, Errno::EROFS, Errno::EPERM
125
+ # Permission denied or read-only filesystem - use temp directory
126
+ require "tmpdir"
127
+ Dir.tmpdir
128
+ end
129
+ end
130
+
131
+ def default_cache_path
132
+ File.join(@root_dir, ".aidp", "deprecated_models.json")
133
+ end
134
+
135
+ def ensure_cache_directory
136
+ dir = File.dirname(@cache_path)
137
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
138
+ end
139
+
140
+ def load_cache
141
+ if File.exist?(@cache_path)
142
+ @cache_data = JSON.parse(File.read(@cache_path))
143
+ validate_cache_structure
144
+ else
145
+ @cache_data = default_cache_structure
146
+ end
147
+ rescue JSON::ParserError => e
148
+ Aidp.log_warn("deprecation_cache", "Invalid cache file, resetting",
149
+ error: e.message, path: @cache_path)
150
+ @cache_data = default_cache_structure
151
+ end
152
+
153
+ def save_cache
154
+ File.write(@cache_path, JSON.pretty_generate(@cache_data))
155
+ rescue => e
156
+ Aidp.log_error("deprecation_cache", "Failed to save cache",
157
+ error: e.message, path: @cache_path)
158
+ raise CacheError, "Failed to save deprecation cache: #{e.message}"
159
+ end
160
+
161
+ def default_cache_structure
162
+ {
163
+ "version" => "1.0",
164
+ "updated_at" => Time.now.iso8601,
165
+ "providers" => {}
166
+ }
167
+ end
168
+
169
+ def validate_cache_structure
170
+ unless @cache_data.is_a?(Hash) && @cache_data["providers"].is_a?(Hash)
171
+ Aidp.log_warn("deprecation_cache", "Invalid cache structure, resetting")
172
+ @cache_data = default_cache_structure
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
+ require_relative "deprecation_cache"
4
5
 
5
6
  module Aidp
6
7
  module Harness
@@ -26,6 +27,11 @@ module Aidp
26
27
  "openrouter" => "openrouter"
27
28
  }.freeze
28
29
 
30
+ # Get deprecation cache instance (lazy loaded)
31
+ def deprecation_cache
32
+ @deprecation_cache ||= Aidp::Harness::DeprecationCache.new
33
+ end
34
+
29
35
  # Tier classification based on model characteristics
30
36
  # These are heuristics since ruby_llm doesn't classify tiers
31
37
  TIER_CLASSIFICATION = {
@@ -59,7 +65,8 @@ module Aidp
59
65
  standard: ->(model) { true }
60
66
  }.freeze
61
67
 
62
- def initialize
68
+ def initialize(deprecation_cache: nil)
69
+ @deprecation_cache = deprecation_cache
63
70
  @models = RubyLLM::Models.instance.instance_variable_get(:@models)
64
71
  @index_by_id = @models.to_h { |m| [m.id, m] }
65
72
 
@@ -73,11 +80,18 @@ module Aidp
73
80
  #
74
81
  # @param model_name [String] Model name (e.g., "claude-3-5-haiku" or "claude-3-5-haiku-20241022")
75
82
  # @param provider [String, nil] Optional AIDP provider filter
83
+ # @param skip_deprecated [Boolean] Skip deprecated models (default: true)
76
84
  # @return [String, nil] Canonical model ID for API calls, or nil if not found
77
- def resolve_model(model_name, provider: nil)
85
+ def resolve_model(model_name, provider: nil, skip_deprecated: true)
78
86
  # Map AIDP provider to registry provider if filtering
79
87
  registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
80
88
 
89
+ # Check if model is deprecated
90
+ if skip_deprecated && model_deprecated?(model_name, registry_provider)
91
+ Aidp.log_warn("ruby_llm_registry", "skipping deprecated model", model: model_name, provider: provider)
92
+ return nil
93
+ end
94
+
81
95
  # Try exact match first
82
96
  model = @index_by_id[model_name]
83
97
  return model.id if model && (registry_provider.nil? || model.provider.to_s == registry_provider)
@@ -88,13 +102,20 @@ module Aidp
88
102
  # Filter by provider if specified
89
103
  family_models = family_models.select { |m| m.provider.to_s == registry_provider } if registry_provider
90
104
 
105
+ # Filter out deprecated models if requested
106
+ if skip_deprecated
107
+ family_models = family_models.reject do |m|
108
+ deprecation_cache.deprecated?(provider: registry_provider, model_id: m.id.to_s)
109
+ end
110
+ end
111
+
91
112
  # Return the latest version (first non-"latest" model, or the latest one)
92
113
  model = family_models.reject { |m| m.id.to_s.include?("-latest") }.first || family_models.first
93
114
  return model.id if model
94
115
  end
95
116
 
96
117
  # Try fuzzy matching for common patterns
97
- fuzzy_match = find_fuzzy_match(model_name, registry_provider)
118
+ fuzzy_match = find_fuzzy_match(model_name, registry_provider, skip_deprecated: skip_deprecated)
98
119
  return fuzzy_match.id if fuzzy_match
99
120
 
100
121
  Aidp.log_warn("ruby_llm_registry", "model not found", model: model_name, provider: provider)
@@ -124,8 +145,9 @@ module Aidp
124
145
  #
125
146
  # @param tier [String, Symbol] The tier name (mini, standard, advanced)
126
147
  # @param provider [String, nil] Optional AIDP provider filter
148
+ # @param skip_deprecated [Boolean] Skip deprecated models (default: true)
127
149
  # @return [Array<String>] List of model IDs for the tier
128
- def models_for_tier(tier, provider: nil)
150
+ def models_for_tier(tier, provider: nil, skip_deprecated: true)
129
151
  tier_sym = tier.to_sym
130
152
  classifier = TIER_CLASSIFICATION[tier_sym]
131
153
 
@@ -152,6 +174,11 @@ module Aidp
152
174
  end
153
175
  end
154
176
 
177
+ # Filter out deprecated models if requested
178
+ if skip_deprecated
179
+ models.reject! { |m| deprecation_cache.deprecated?(provider: registry_provider, model_id: m.id.to_s) }
180
+ end
181
+
155
182
  model_ids = models.map(&:id).uniq
156
183
  Aidp.log_debug("ruby_llm_registry", "found models for tier",
157
184
  tier: tier, provider: provider, count: model_ids.size)
@@ -191,6 +218,51 @@ module Aidp
191
218
  Aidp.log_info("ruby_llm_registry", "refreshed", models: @models.size)
192
219
  end
193
220
 
221
+ # Check if a model is deprecated
222
+ # @param model_id [String] The model ID to check
223
+ # @param provider [String, nil] The provider name (registry format)
224
+ # @return [Boolean] True if model is deprecated
225
+ def model_deprecated?(model_id, provider = nil)
226
+ return false unless provider
227
+
228
+ deprecation_cache.deprecated?(provider: provider, model_id: model_id.to_s)
229
+ end
230
+
231
+ # Find replacement for a deprecated model
232
+ # Returns the latest non-deprecated model in the same family/tier
233
+ # @param deprecated_model [String] The deprecated model ID
234
+ # @param provider [String, nil] The provider name (AIDP format)
235
+ # @return [String, nil] Replacement model ID or nil
236
+ def find_replacement_model(deprecated_model, provider: nil)
237
+ registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
238
+ return nil unless registry_provider
239
+
240
+ # Determine tier of deprecated model
241
+ deprecated_info = @index_by_id[deprecated_model]
242
+ return nil unless deprecated_info
243
+
244
+ tier = classify_tier(deprecated_info)
245
+
246
+ # Get all non-deprecated models for this tier and provider
247
+ candidates = models_for_tier(tier, provider: provider, skip_deprecated: true)
248
+
249
+ # Prefer models in the same family (e.g., both "sonnet")
250
+ family_keyword = extract_family_keyword(deprecated_model)
251
+ same_family = candidates.select { |m| m.to_s.include?(family_keyword) } if family_keyword
252
+
253
+ # Return first match from same family, or first candidate overall
254
+ replacement = same_family&.first || candidates.first
255
+
256
+ if replacement
257
+ Aidp.log_info("ruby_llm_registry", "found replacement",
258
+ deprecated: deprecated_model,
259
+ replacement: replacement,
260
+ tier: tier)
261
+ end
262
+
263
+ replacement
264
+ end
265
+
194
266
  private
195
267
 
196
268
  # Build an index mapping family names to model objects
@@ -208,13 +280,18 @@ module Aidp
208
280
  end
209
281
 
210
282
  # Find a model by fuzzy matching
211
- def find_fuzzy_match(model_name, provider)
283
+ def find_fuzzy_match(model_name, provider, skip_deprecated: true)
212
284
  # Normalize the search term
213
285
  normalized = model_name.downcase.gsub(/[^a-z0-9]/, "")
214
286
 
215
287
  candidates = @models.select do |m|
216
288
  next false if provider && m.provider.to_s != provider
217
289
 
290
+ # Skip deprecated if requested
291
+ if skip_deprecated
292
+ next false if deprecation_cache.deprecated?(provider: provider, model_id: m.id.to_s)
293
+ end
294
+
218
295
  # Check if model ID contains the search term
219
296
  m.id.to_s.downcase.gsub(/[^a-z0-9]/, "").include?(normalized) ||
220
297
  m.name.to_s.downcase.gsub(/[^a-z0-9]/, "").include?(normalized)
@@ -224,6 +301,17 @@ module Aidp
224
301
  candidates.min_by { |m| m.id.to_s.length }
225
302
  end
226
303
 
304
+ # Extract family keyword from model ID (e.g., "sonnet", "haiku", "opus")
305
+ def extract_family_keyword(model_id)
306
+ case model_id.to_s
307
+ when /sonnet/i then "sonnet"
308
+ when /haiku/i then "haiku"
309
+ when /opus/i then "opus"
310
+ when /gpt-4/i then "gpt-4"
311
+ when /gpt-3/i then "gpt-3"
312
+ end
313
+ end
314
+
227
315
  # Extract capabilities from model info
228
316
  def extract_capabilities(model)
229
317
  caps = []
@@ -160,11 +160,53 @@ module Aidp
160
160
  if configured_models.any?
161
161
  # Use first configured model for this provider and tier
162
162
  model_name = configured_models.first
163
- Aidp.log_debug("thinking_depth_manager", "Selected model from user config",
164
- tier: tier,
165
- provider: provider,
166
- model: model_name)
167
- return [provider, model_name, {}]
163
+
164
+ # Check if model is deprecated and try to upgrade
165
+ require_relative "ruby_llm_registry" unless defined?(Aidp::Harness::RubyLLMRegistry)
166
+ llm_registry = Aidp::Harness::RubyLLMRegistry.new
167
+
168
+ if llm_registry.model_deprecated?(model_name, provider)
169
+ Aidp.log_warn("thinking_depth_manager", "Configured model is deprecated",
170
+ tier: tier,
171
+ provider: provider,
172
+ model: model_name)
173
+
174
+ # Try to find replacement
175
+ replacement = llm_registry.find_replacement_model(model_name, provider: provider)
176
+ if replacement
177
+ Aidp.log_info("thinking_depth_manager", "Auto-upgrading to non-deprecated model",
178
+ tier: tier,
179
+ provider: provider,
180
+ old_model: model_name,
181
+ new_model: replacement)
182
+ model_name = replacement
183
+ else
184
+ # Try next model in config list
185
+ non_deprecated = configured_models.find { |m| !llm_registry.model_deprecated?(m, provider) }
186
+ if non_deprecated
187
+ Aidp.log_info("thinking_depth_manager", "Using alternate configured model",
188
+ tier: tier,
189
+ provider: provider,
190
+ skipped: model_name,
191
+ selected: non_deprecated)
192
+ model_name = non_deprecated
193
+ else
194
+ Aidp.log_warn("thinking_depth_manager", "All configured models deprecated, falling back to catalog",
195
+ tier: tier,
196
+ provider: provider)
197
+ # Fall through to catalog selection
198
+ model_name = nil
199
+ end
200
+ end
201
+ end
202
+
203
+ if model_name
204
+ Aidp.log_debug("thinking_depth_manager", "Selected model from user config",
205
+ tier: tier,
206
+ provider: provider,
207
+ model: model_name)
208
+ return [provider, model_name, {}]
209
+ end
168
210
  end
169
211
 
170
212
  # Provider specified but has no models for this tier in config
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "base"
5
5
  require_relative "../debug_mixin"
6
+ require_relative "../harness/deprecation_cache"
6
7
 
7
8
  module Aidp
8
9
  module Providers
@@ -14,6 +15,11 @@ module Aidp
14
15
  # Model name pattern for Anthropic Claude models
15
16
  MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
16
17
 
18
+ # Get deprecation cache instance (lazy loaded)
19
+ def self.deprecation_cache
20
+ @deprecation_cache ||= Aidp::Harness::DeprecationCache.new
21
+ end
22
+
17
23
  def self.available?
18
24
  !!Aidp::Util.which("claude")
19
25
  end
@@ -212,6 +218,56 @@ module Aidp
212
218
  ["--dangerously-skip-permissions"]
213
219
  end
214
220
 
221
+ # Classify provider error using string matching
222
+ #
223
+ # ZFC EXCEPTION: Cannot use AI to classify provider errors because:
224
+ # 1. The failing provider IS the AI we'd use for classification (circular dependency)
225
+ # 2. Provider may be rate-limited, down, or misconfigured
226
+ # 3. Error classification must work even when AI unavailable
227
+ #
228
+ # This is a legitimate exception to ZFC principles per LLM_STYLE_GUIDE:
229
+ # "Structural safety checks" are allowed in code when AI cannot be used.
230
+ #
231
+ # @param error_message [String] The error message to classify
232
+ # @return [Hash] Classification result with :type, :is_deprecation, :is_rate_limit, :confidence
233
+ def self.classify_provider_error(error_message)
234
+ msg_lower = error_message.downcase
235
+
236
+ # Use simple string.include? checks (not regex) to avoid ReDoS vulnerabilities
237
+ is_rate_limit = msg_lower.include?("rate limit") || msg_lower.include?("session limit")
238
+ is_deprecation = msg_lower.include?("deprecat") || msg_lower.include?("end-of-life")
239
+ is_auth_error = msg_lower.include?("auth") && (msg_lower.include?("expired") || msg_lower.include?("invalid"))
240
+
241
+ # Determine primary type
242
+ type = if is_rate_limit
243
+ "rate_limit"
244
+ elsif is_deprecation
245
+ "deprecation"
246
+ elsif is_auth_error
247
+ "auth_error"
248
+ else
249
+ "other"
250
+ end
251
+
252
+ Aidp.log_debug("anthropic", "Provider error classification",
253
+ type: type,
254
+ is_rate_limit: is_rate_limit,
255
+ is_deprecation: is_deprecation,
256
+ is_auth_error: is_auth_error)
257
+
258
+ {
259
+ type: type,
260
+ is_rate_limit: is_rate_limit,
261
+ is_deprecation: is_deprecation,
262
+ is_auth_error: is_auth_error,
263
+ confidence: 0.85, # Good confidence for clear error messages
264
+ reasoning: "Pattern-based classification (ZFC exception: circular dependency)"
265
+ }
266
+ end
267
+
268
+ # Error patterns for classify_error method (legacy support)
269
+ # NOTE: ZFC-based classification preferred - see classify_error_with_zfc
270
+ # These patterns serve as fallback when ZFC is unavailable
215
271
  def error_patterns
216
272
  {
217
273
  rate_limited: [
@@ -247,14 +303,72 @@ module Aidp
247
303
  /not.*found/i,
248
304
  /404/,
249
305
  /bad.*request/i,
250
- /400/
306
+ /400/,
307
+ /model.*deprecated/i,
308
+ /end-of-life/i
251
309
  ]
252
310
  }
253
311
  end
254
312
 
313
+ # Check if a model is deprecated and return replacement
314
+ # @param model_name [String] The model name to check
315
+ # @return [String, nil] Replacement model name if deprecated, nil otherwise
316
+ def self.check_model_deprecation(model_name)
317
+ deprecation_cache.replacement_for(provider: "anthropic", model_id: model_name)
318
+ end
319
+
320
+ # Find replacement model for deprecated one using RubyLLM registry
321
+ # @param deprecated_model [String] The deprecated model name
322
+ # @param provider_name [String] Provider name for registry lookup
323
+ # @return [String, nil] Latest model in the same family, or configured replacement
324
+ def self.find_replacement_model(deprecated_model, provider_name: "anthropic")
325
+ # First check the deprecation cache for explicit replacement
326
+ replacement = deprecation_cache.replacement_for(provider: provider_name, model_id: deprecated_model)
327
+ return replacement if replacement
328
+
329
+ # Try to find latest model in same family using registry
330
+ require_relative "../harness/ruby_llm_registry" unless defined?(Aidp::Harness::RubyLLMRegistry)
331
+
332
+ begin
333
+ registry = Aidp::Harness::RubyLLMRegistry.new
334
+
335
+ # Search for non-deprecated models in the same family
336
+ # Prefer models without "latest" suffix, sorted by ID (newer dates first)
337
+ models = registry.models_for_provider(provider_name)
338
+ candidates = models.select { |m| m.include?("sonnet") && !deprecation_cache.deprecated?(provider: provider_name, model_id: m) }
339
+
340
+ # Prioritize: versioned models over -latest, newer versions first
341
+ versioned = candidates.reject { |m| m.end_with?("-latest") }.sort.reverse
342
+ latest_models = candidates.select { |m| m.end_with?("-latest") }
343
+
344
+ replacement = versioned.first || latest_models.first
345
+
346
+ if replacement
347
+ Aidp.log_info("anthropic", "Found replacement model",
348
+ deprecated: deprecated_model,
349
+ replacement: replacement)
350
+ end
351
+
352
+ replacement
353
+ rescue => e
354
+ Aidp.log_error("anthropic", "Failed to find replacement model",
355
+ deprecated: deprecated_model,
356
+ error: e.message)
357
+ nil
358
+ end
359
+ end
360
+
255
361
  def send_message(prompt:, session: nil, options: {})
256
362
  raise "claude CLI not available" unless self.class.available?
257
363
 
364
+ # Check if current model is deprecated and warn
365
+ if @model && (replacement = self.class.check_model_deprecation(@model))
366
+ Aidp.log_warn("anthropic", "Using deprecated model",
367
+ current: @model,
368
+ replacement: replacement)
369
+ debug_log("⚠️ Model #{@model} is deprecated. Consider upgrading to #{replacement}", level: :warn)
370
+ end
371
+
258
372
  # Smart timeout calculation with tier awareness
259
373
  timeout_seconds = calculate_timeout(options)
260
374
 
@@ -287,15 +401,72 @@ module Aidp
287
401
  # Detect issues in stdout/stderr (Claude sometimes prints to stdout)
288
402
  combined = [result.out, result.err].compact.join("\n")
289
403
 
290
- # Check for rate limit (Session limit reached)
291
- if combined.match?(/session limit reached/i)
404
+ # Classify provider error using pattern matching
405
+ # ZFC EXCEPTION: Cannot use AI to classify provider's own errors (circular dependency)
406
+ error_classification = self.class.classify_provider_error(combined)
407
+
408
+ Aidp.log_debug("anthropic_provider", "error_classified",
409
+ exit_code: result.exit_status,
410
+ type: error_classification[:type],
411
+ confidence: error_classification[:confidence]) # Check for rate limit
412
+ if error_classification[:is_rate_limit]
292
413
  Aidp.log_debug("anthropic_provider", "rate_limit_detected",
293
414
  exit_code: result.exit_status,
415
+ confidence: error_classification[:confidence],
294
416
  message: combined)
295
417
  notify_rate_limit(combined)
296
418
  error_message = "Rate limit reached for Claude CLI.\n#{combined}"
297
- debug_error(StandardError.new(error_message), {exit_code: result.exit_status, stdout: result.out, stderr: result.err})
298
- raise error_message
419
+ error = RuntimeError.new(error_message)
420
+ debug_error(error, {exit_code: result.exit_status, stdout: result.out, stderr: result.err})
421
+ raise error
422
+ end
423
+
424
+ # Check for model deprecation
425
+ if error_classification[:is_deprecation]
426
+ deprecated_model = @model
427
+ Aidp.log_error("anthropic", "Model deprecation detected",
428
+ model: deprecated_model,
429
+ message: combined)
430
+
431
+ # Try to find replacement
432
+ replacement = deprecated_model ? self.class.find_replacement_model(deprecated_model) : nil
433
+
434
+ # Record deprecation in cache for future runs
435
+ if replacement
436
+ self.class.deprecation_cache.add_deprecated_model(
437
+ provider: "anthropic",
438
+ model_id: deprecated_model,
439
+ replacement: replacement,
440
+ reason: combined.lines.first&.strip || "Model deprecated"
441
+ )
442
+
443
+ Aidp.log_info("anthropic", "Auto-upgrading to non-deprecated model",
444
+ old_model: deprecated_model,
445
+ new_model: replacement)
446
+ debug_log("🔄 Upgrading from deprecated model #{deprecated_model} to #{replacement}", level: :info)
447
+
448
+ # Update model and retry
449
+ @model = replacement
450
+
451
+ # Retry with new model
452
+ debug_log("🔄 Retrying with upgraded model: #{replacement}", level: :info)
453
+ return send_message(prompt: prompt, session: session, options: options)
454
+ else
455
+ # Record deprecation even without replacement
456
+ if deprecated_model
457
+ self.class.deprecation_cache.add_deprecated_model(
458
+ provider: "anthropic",
459
+ model_id: deprecated_model,
460
+ replacement: nil,
461
+ reason: combined.lines.first&.strip || "Model deprecated"
462
+ )
463
+ end
464
+
465
+ error_message = "Model '#{deprecated_model}' is deprecated and no replacement found.\n#{combined}"
466
+ error = RuntimeError.new(error_message)
467
+ debug_error(error, {exit_code: result.exit_status, stdout: result.out, stderr: result.err})
468
+ raise error
469
+ end
299
470
  end
300
471
 
301
472
  # Check for auth issues
@@ -303,12 +474,15 @@ module Aidp
303
474
  error_message = "Authentication error from Claude CLI: token expired or invalid.\n" \
304
475
  "Run 'claude /login' or refresh credentials.\n" \
305
476
  "Note: Model discovery requires valid authentication."
306
- debug_error(StandardError.new(error_message), {exit_code: result.exit_status, stdout: result.out, stderr: result.err})
307
- raise error_message
477
+ error = RuntimeError.new(error_message)
478
+ debug_error(error, {exit_code: result.exit_status, stdout: result.out, stderr: result.err})
479
+ raise error
308
480
  end
309
481
 
310
- debug_error(StandardError.new("claude failed"), {exit_code: result.exit_status, stderr: result.err})
311
- raise "claude failed with exit code #{result.exit_status}: #{result.err}"
482
+ error_message = "claude failed with exit code #{result.exit_status}: #{result.err}"
483
+ error = RuntimeError.new(error_message)
484
+ debug_error(error, {exit_code: result.exit_status, stderr: result.err})
485
+ raise error
312
486
  end
313
487
  rescue => e
314
488
  debug_error(e, {provider: "claude", prompt_length: prompt.length})
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.29.0"
4
+ VERSION = "0.30.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.0
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -324,6 +324,7 @@ files:
324
324
  - lib/aidp/harness/config_schema.rb
325
325
  - lib/aidp/harness/config_validator.rb
326
326
  - lib/aidp/harness/configuration.rb
327
+ - lib/aidp/harness/deprecation_cache.rb
327
328
  - lib/aidp/harness/enhanced_runner.rb
328
329
  - lib/aidp/harness/error_handler.rb
329
330
  - lib/aidp/harness/filter_strategy.rb