attune-ai 2.0.0__py3-none-any.whl
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.
- attune/__init__.py +358 -0
- attune/adaptive/__init__.py +13 -0
- attune/adaptive/task_complexity.py +127 -0
- attune/agent_monitoring.py +414 -0
- attune/cache/__init__.py +117 -0
- attune/cache/base.py +166 -0
- attune/cache/dependency_manager.py +256 -0
- attune/cache/hash_only.py +251 -0
- attune/cache/hybrid.py +457 -0
- attune/cache/storage.py +285 -0
- attune/cache_monitor.py +356 -0
- attune/cache_stats.py +298 -0
- attune/cli/__init__.py +152 -0
- attune/cli/__main__.py +12 -0
- attune/cli/commands/__init__.py +1 -0
- attune/cli/commands/batch.py +264 -0
- attune/cli/commands/cache.py +248 -0
- attune/cli/commands/help.py +331 -0
- attune/cli/commands/info.py +140 -0
- attune/cli/commands/inspect.py +436 -0
- attune/cli/commands/inspection.py +57 -0
- attune/cli/commands/memory.py +48 -0
- attune/cli/commands/metrics.py +92 -0
- attune/cli/commands/orchestrate.py +184 -0
- attune/cli/commands/patterns.py +207 -0
- attune/cli/commands/profiling.py +202 -0
- attune/cli/commands/provider.py +98 -0
- attune/cli/commands/routing.py +285 -0
- attune/cli/commands/setup.py +96 -0
- attune/cli/commands/status.py +235 -0
- attune/cli/commands/sync.py +166 -0
- attune/cli/commands/tier.py +121 -0
- attune/cli/commands/utilities.py +114 -0
- attune/cli/commands/workflow.py +579 -0
- attune/cli/core.py +32 -0
- attune/cli/parsers/__init__.py +68 -0
- attune/cli/parsers/batch.py +118 -0
- attune/cli/parsers/cache.py +65 -0
- attune/cli/parsers/help.py +41 -0
- attune/cli/parsers/info.py +26 -0
- attune/cli/parsers/inspect.py +66 -0
- attune/cli/parsers/metrics.py +42 -0
- attune/cli/parsers/orchestrate.py +61 -0
- attune/cli/parsers/patterns.py +54 -0
- attune/cli/parsers/provider.py +40 -0
- attune/cli/parsers/routing.py +110 -0
- attune/cli/parsers/setup.py +42 -0
- attune/cli/parsers/status.py +47 -0
- attune/cli/parsers/sync.py +31 -0
- attune/cli/parsers/tier.py +33 -0
- attune/cli/parsers/workflow.py +77 -0
- attune/cli/utils/__init__.py +1 -0
- attune/cli/utils/data.py +242 -0
- attune/cli/utils/helpers.py +68 -0
- attune/cli_legacy.py +3957 -0
- attune/cli_minimal.py +1159 -0
- attune/cli_router.py +437 -0
- attune/cli_unified.py +814 -0
- attune/config/__init__.py +66 -0
- attune/config/xml_config.py +286 -0
- attune/config.py +545 -0
- attune/coordination.py +870 -0
- attune/core.py +1511 -0
- attune/core_modules/__init__.py +15 -0
- attune/cost_tracker.py +626 -0
- attune/dashboard/__init__.py +41 -0
- attune/dashboard/app.py +512 -0
- attune/dashboard/simple_server.py +435 -0
- attune/dashboard/standalone_server.py +547 -0
- attune/discovery.py +306 -0
- attune/emergence.py +306 -0
- attune/exceptions.py +123 -0
- attune/feedback_loops.py +373 -0
- attune/hot_reload/README.md +473 -0
- attune/hot_reload/__init__.py +62 -0
- attune/hot_reload/config.py +83 -0
- attune/hot_reload/integration.py +229 -0
- attune/hot_reload/reloader.py +298 -0
- attune/hot_reload/watcher.py +183 -0
- attune/hot_reload/websocket.py +177 -0
- attune/levels.py +577 -0
- attune/leverage_points.py +441 -0
- attune/logging_config.py +261 -0
- attune/mcp/__init__.py +10 -0
- attune/mcp/server.py +506 -0
- attune/memory/__init__.py +237 -0
- attune/memory/claude_memory.py +469 -0
- attune/memory/config.py +224 -0
- attune/memory/control_panel.py +1290 -0
- attune/memory/control_panel_support.py +145 -0
- attune/memory/cross_session.py +845 -0
- attune/memory/edges.py +179 -0
- attune/memory/encryption.py +159 -0
- attune/memory/file_session.py +770 -0
- attune/memory/graph.py +570 -0
- attune/memory/long_term.py +913 -0
- attune/memory/long_term_types.py +99 -0
- attune/memory/mixins/__init__.py +25 -0
- attune/memory/mixins/backend_init_mixin.py +249 -0
- attune/memory/mixins/capabilities_mixin.py +208 -0
- attune/memory/mixins/handoff_mixin.py +208 -0
- attune/memory/mixins/lifecycle_mixin.py +49 -0
- attune/memory/mixins/long_term_mixin.py +352 -0
- attune/memory/mixins/promotion_mixin.py +109 -0
- attune/memory/mixins/short_term_mixin.py +182 -0
- attune/memory/nodes.py +179 -0
- attune/memory/redis_bootstrap.py +540 -0
- attune/memory/security/__init__.py +31 -0
- attune/memory/security/audit_logger.py +932 -0
- attune/memory/security/pii_scrubber.py +640 -0
- attune/memory/security/secrets_detector.py +678 -0
- attune/memory/short_term.py +2192 -0
- attune/memory/simple_storage.py +302 -0
- attune/memory/storage/__init__.py +15 -0
- attune/memory/storage_backend.py +167 -0
- attune/memory/summary_index.py +583 -0
- attune/memory/types.py +446 -0
- attune/memory/unified.py +182 -0
- attune/meta_workflows/__init__.py +74 -0
- attune/meta_workflows/agent_creator.py +248 -0
- attune/meta_workflows/builtin_templates.py +567 -0
- attune/meta_workflows/cli_commands/__init__.py +56 -0
- attune/meta_workflows/cli_commands/agent_commands.py +321 -0
- attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
- attune/meta_workflows/cli_commands/config_commands.py +232 -0
- attune/meta_workflows/cli_commands/memory_commands.py +182 -0
- attune/meta_workflows/cli_commands/template_commands.py +354 -0
- attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
- attune/meta_workflows/cli_meta_workflows.py +59 -0
- attune/meta_workflows/form_engine.py +292 -0
- attune/meta_workflows/intent_detector.py +409 -0
- attune/meta_workflows/models.py +569 -0
- attune/meta_workflows/pattern_learner.py +738 -0
- attune/meta_workflows/plan_generator.py +384 -0
- attune/meta_workflows/session_context.py +397 -0
- attune/meta_workflows/template_registry.py +229 -0
- attune/meta_workflows/workflow.py +984 -0
- attune/metrics/__init__.py +12 -0
- attune/metrics/collector.py +31 -0
- attune/metrics/prompt_metrics.py +194 -0
- attune/models/__init__.py +172 -0
- attune/models/__main__.py +13 -0
- attune/models/adaptive_routing.py +437 -0
- attune/models/auth_cli.py +444 -0
- attune/models/auth_strategy.py +450 -0
- attune/models/cli.py +655 -0
- attune/models/empathy_executor.py +354 -0
- attune/models/executor.py +257 -0
- attune/models/fallback.py +762 -0
- attune/models/provider_config.py +282 -0
- attune/models/registry.py +472 -0
- attune/models/tasks.py +359 -0
- attune/models/telemetry/__init__.py +71 -0
- attune/models/telemetry/analytics.py +594 -0
- attune/models/telemetry/backend.py +196 -0
- attune/models/telemetry/data_models.py +431 -0
- attune/models/telemetry/storage.py +489 -0
- attune/models/token_estimator.py +420 -0
- attune/models/validation.py +280 -0
- attune/monitoring/__init__.py +52 -0
- attune/monitoring/alerts.py +946 -0
- attune/monitoring/alerts_cli.py +448 -0
- attune/monitoring/multi_backend.py +271 -0
- attune/monitoring/otel_backend.py +362 -0
- attune/optimization/__init__.py +19 -0
- attune/optimization/context_optimizer.py +272 -0
- attune/orchestration/__init__.py +67 -0
- attune/orchestration/agent_templates.py +707 -0
- attune/orchestration/config_store.py +499 -0
- attune/orchestration/execution_strategies.py +2111 -0
- attune/orchestration/meta_orchestrator.py +1168 -0
- attune/orchestration/pattern_learner.py +696 -0
- attune/orchestration/real_tools.py +931 -0
- attune/pattern_cache.py +187 -0
- attune/pattern_library.py +542 -0
- attune/patterns/debugging/all_patterns.json +81 -0
- attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- attune/patterns/refactoring_memory.json +89 -0
- attune/persistence.py +564 -0
- attune/platform_utils.py +265 -0
- attune/plugins/__init__.py +28 -0
- attune/plugins/base.py +361 -0
- attune/plugins/registry.py +268 -0
- attune/project_index/__init__.py +32 -0
- attune/project_index/cli.py +335 -0
- attune/project_index/index.py +667 -0
- attune/project_index/models.py +504 -0
- attune/project_index/reports.py +474 -0
- attune/project_index/scanner.py +777 -0
- attune/project_index/scanner_parallel.py +291 -0
- attune/prompts/__init__.py +61 -0
- attune/prompts/config.py +77 -0
- attune/prompts/context.py +177 -0
- attune/prompts/parser.py +285 -0
- attune/prompts/registry.py +313 -0
- attune/prompts/templates.py +208 -0
- attune/redis_config.py +302 -0
- attune/redis_memory.py +799 -0
- attune/resilience/__init__.py +56 -0
- attune/resilience/circuit_breaker.py +256 -0
- attune/resilience/fallback.py +179 -0
- attune/resilience/health.py +300 -0
- attune/resilience/retry.py +209 -0
- attune/resilience/timeout.py +135 -0
- attune/routing/__init__.py +43 -0
- attune/routing/chain_executor.py +433 -0
- attune/routing/classifier.py +217 -0
- attune/routing/smart_router.py +234 -0
- attune/routing/workflow_registry.py +343 -0
- attune/scaffolding/README.md +589 -0
- attune/scaffolding/__init__.py +35 -0
- attune/scaffolding/__main__.py +14 -0
- attune/scaffolding/cli.py +240 -0
- attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
- attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
- attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
- attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
- attune/socratic/__init__.py +256 -0
- attune/socratic/ab_testing.py +958 -0
- attune/socratic/blueprint.py +533 -0
- attune/socratic/cli.py +703 -0
- attune/socratic/collaboration.py +1114 -0
- attune/socratic/domain_templates.py +924 -0
- attune/socratic/embeddings.py +738 -0
- attune/socratic/engine.py +794 -0
- attune/socratic/explainer.py +682 -0
- attune/socratic/feedback.py +772 -0
- attune/socratic/forms.py +629 -0
- attune/socratic/generator.py +732 -0
- attune/socratic/llm_analyzer.py +637 -0
- attune/socratic/mcp_server.py +702 -0
- attune/socratic/session.py +312 -0
- attune/socratic/storage.py +667 -0
- attune/socratic/success.py +730 -0
- attune/socratic/visual_editor.py +860 -0
- attune/socratic/web_ui.py +958 -0
- attune/telemetry/__init__.py +39 -0
- attune/telemetry/agent_coordination.py +475 -0
- attune/telemetry/agent_tracking.py +367 -0
- attune/telemetry/approval_gates.py +545 -0
- attune/telemetry/cli.py +1231 -0
- attune/telemetry/commands/__init__.py +14 -0
- attune/telemetry/commands/dashboard_commands.py +696 -0
- attune/telemetry/event_streaming.py +409 -0
- attune/telemetry/feedback_loop.py +567 -0
- attune/telemetry/usage_tracker.py +591 -0
- attune/templates.py +754 -0
- attune/test_generator/__init__.py +38 -0
- attune/test_generator/__main__.py +14 -0
- attune/test_generator/cli.py +234 -0
- attune/test_generator/generator.py +355 -0
- attune/test_generator/risk_analyzer.py +216 -0
- attune/test_generator/templates/unit_test.py.jinja2 +272 -0
- attune/tier_recommender.py +384 -0
- attune/tools.py +183 -0
- attune/trust/__init__.py +28 -0
- attune/trust/circuit_breaker.py +579 -0
- attune/trust_building.py +527 -0
- attune/validation/__init__.py +19 -0
- attune/validation/xml_validator.py +281 -0
- attune/vscode_bridge.py +173 -0
- attune/workflow_commands.py +780 -0
- attune/workflow_patterns/__init__.py +33 -0
- attune/workflow_patterns/behavior.py +249 -0
- attune/workflow_patterns/core.py +76 -0
- attune/workflow_patterns/output.py +99 -0
- attune/workflow_patterns/registry.py +255 -0
- attune/workflow_patterns/structural.py +288 -0
- attune/workflows/__init__.py +539 -0
- attune/workflows/autonomous_test_gen.py +1268 -0
- attune/workflows/base.py +2667 -0
- attune/workflows/batch_processing.py +342 -0
- attune/workflows/bug_predict.py +1084 -0
- attune/workflows/builder.py +273 -0
- attune/workflows/caching.py +253 -0
- attune/workflows/code_review.py +1048 -0
- attune/workflows/code_review_adapters.py +312 -0
- attune/workflows/code_review_pipeline.py +722 -0
- attune/workflows/config.py +645 -0
- attune/workflows/dependency_check.py +644 -0
- attune/workflows/document_gen/__init__.py +25 -0
- attune/workflows/document_gen/config.py +30 -0
- attune/workflows/document_gen/report_formatter.py +162 -0
- attune/workflows/document_gen/workflow.py +1426 -0
- attune/workflows/document_manager.py +216 -0
- attune/workflows/document_manager_README.md +134 -0
- attune/workflows/documentation_orchestrator.py +1205 -0
- attune/workflows/history.py +510 -0
- attune/workflows/keyboard_shortcuts/__init__.py +39 -0
- attune/workflows/keyboard_shortcuts/generators.py +391 -0
- attune/workflows/keyboard_shortcuts/parsers.py +416 -0
- attune/workflows/keyboard_shortcuts/prompts.py +295 -0
- attune/workflows/keyboard_shortcuts/schema.py +193 -0
- attune/workflows/keyboard_shortcuts/workflow.py +509 -0
- attune/workflows/llm_base.py +363 -0
- attune/workflows/manage_docs.py +87 -0
- attune/workflows/manage_docs_README.md +134 -0
- attune/workflows/manage_documentation.py +821 -0
- attune/workflows/new_sample_workflow1.py +149 -0
- attune/workflows/new_sample_workflow1_README.md +150 -0
- attune/workflows/orchestrated_health_check.py +849 -0
- attune/workflows/orchestrated_release_prep.py +600 -0
- attune/workflows/output.py +413 -0
- attune/workflows/perf_audit.py +863 -0
- attune/workflows/pr_review.py +762 -0
- attune/workflows/progress.py +785 -0
- attune/workflows/progress_server.py +322 -0
- attune/workflows/progressive/README 2.md +454 -0
- attune/workflows/progressive/README.md +454 -0
- attune/workflows/progressive/__init__.py +82 -0
- attune/workflows/progressive/cli.py +219 -0
- attune/workflows/progressive/core.py +488 -0
- attune/workflows/progressive/orchestrator.py +723 -0
- attune/workflows/progressive/reports.py +520 -0
- attune/workflows/progressive/telemetry.py +274 -0
- attune/workflows/progressive/test_gen.py +495 -0
- attune/workflows/progressive/workflow.py +589 -0
- attune/workflows/refactor_plan.py +694 -0
- attune/workflows/release_prep.py +895 -0
- attune/workflows/release_prep_crew.py +969 -0
- attune/workflows/research_synthesis.py +404 -0
- attune/workflows/routing.py +168 -0
- attune/workflows/secure_release.py +593 -0
- attune/workflows/security_adapters.py +297 -0
- attune/workflows/security_audit.py +1329 -0
- attune/workflows/security_audit_phase3.py +355 -0
- attune/workflows/seo_optimization.py +633 -0
- attune/workflows/step_config.py +234 -0
- attune/workflows/telemetry_mixin.py +269 -0
- attune/workflows/test5.py +125 -0
- attune/workflows/test5_README.md +158 -0
- attune/workflows/test_coverage_boost_crew.py +849 -0
- attune/workflows/test_gen/__init__.py +52 -0
- attune/workflows/test_gen/ast_analyzer.py +249 -0
- attune/workflows/test_gen/config.py +88 -0
- attune/workflows/test_gen/data_models.py +38 -0
- attune/workflows/test_gen/report_formatter.py +289 -0
- attune/workflows/test_gen/test_templates.py +381 -0
- attune/workflows/test_gen/workflow.py +655 -0
- attune/workflows/test_gen.py +54 -0
- attune/workflows/test_gen_behavioral.py +477 -0
- attune/workflows/test_gen_parallel.py +341 -0
- attune/workflows/test_lifecycle.py +526 -0
- attune/workflows/test_maintenance.py +627 -0
- attune/workflows/test_maintenance_cli.py +590 -0
- attune/workflows/test_maintenance_crew.py +840 -0
- attune/workflows/test_runner.py +622 -0
- attune/workflows/tier_tracking.py +531 -0
- attune/workflows/xml_enhanced_crew.py +285 -0
- attune_ai-2.0.0.dist-info/METADATA +1026 -0
- attune_ai-2.0.0.dist-info/RECORD +457 -0
- attune_ai-2.0.0.dist-info/WHEEL +5 -0
- attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
- attune_ai-2.0.0.dist-info/top_level.txt +5 -0
- attune_healthcare/__init__.py +13 -0
- attune_healthcare/monitors/__init__.py +9 -0
- attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
- attune_healthcare/monitors/monitoring/__init__.py +44 -0
- attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
- attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
- attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
- attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
- attune_llm/README.md +553 -0
- attune_llm/__init__.py +28 -0
- attune_llm/agent_factory/__init__.py +53 -0
- attune_llm/agent_factory/adapters/__init__.py +85 -0
- attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
- attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
- attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
- attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
- attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
- attune_llm/agent_factory/adapters/native.py +228 -0
- attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
- attune_llm/agent_factory/base.py +305 -0
- attune_llm/agent_factory/crews/__init__.py +67 -0
- attune_llm/agent_factory/crews/code_review.py +1113 -0
- attune_llm/agent_factory/crews/health_check.py +1262 -0
- attune_llm/agent_factory/crews/refactoring.py +1128 -0
- attune_llm/agent_factory/crews/security_audit.py +1018 -0
- attune_llm/agent_factory/decorators.py +287 -0
- attune_llm/agent_factory/factory.py +558 -0
- attune_llm/agent_factory/framework.py +193 -0
- attune_llm/agent_factory/memory_integration.py +328 -0
- attune_llm/agent_factory/resilient.py +320 -0
- attune_llm/agents_md/__init__.py +22 -0
- attune_llm/agents_md/loader.py +218 -0
- attune_llm/agents_md/parser.py +271 -0
- attune_llm/agents_md/registry.py +307 -0
- attune_llm/claude_memory.py +466 -0
- attune_llm/cli/__init__.py +8 -0
- attune_llm/cli/sync_claude.py +487 -0
- attune_llm/code_health.py +1313 -0
- attune_llm/commands/__init__.py +51 -0
- attune_llm/commands/context.py +375 -0
- attune_llm/commands/loader.py +301 -0
- attune_llm/commands/models.py +231 -0
- attune_llm/commands/parser.py +371 -0
- attune_llm/commands/registry.py +429 -0
- attune_llm/config/__init__.py +29 -0
- attune_llm/config/unified.py +291 -0
- attune_llm/context/__init__.py +22 -0
- attune_llm/context/compaction.py +455 -0
- attune_llm/context/manager.py +434 -0
- attune_llm/contextual_patterns.py +361 -0
- attune_llm/core.py +907 -0
- attune_llm/git_pattern_extractor.py +435 -0
- attune_llm/hooks/__init__.py +24 -0
- attune_llm/hooks/config.py +306 -0
- attune_llm/hooks/executor.py +289 -0
- attune_llm/hooks/registry.py +302 -0
- attune_llm/hooks/scripts/__init__.py +39 -0
- attune_llm/hooks/scripts/evaluate_session.py +201 -0
- attune_llm/hooks/scripts/first_time_init.py +285 -0
- attune_llm/hooks/scripts/pre_compact.py +207 -0
- attune_llm/hooks/scripts/session_end.py +183 -0
- attune_llm/hooks/scripts/session_start.py +163 -0
- attune_llm/hooks/scripts/suggest_compact.py +225 -0
- attune_llm/learning/__init__.py +30 -0
- attune_llm/learning/evaluator.py +438 -0
- attune_llm/learning/extractor.py +514 -0
- attune_llm/learning/storage.py +560 -0
- attune_llm/levels.py +227 -0
- attune_llm/pattern_confidence.py +414 -0
- attune_llm/pattern_resolver.py +272 -0
- attune_llm/pattern_summary.py +350 -0
- attune_llm/providers.py +967 -0
- attune_llm/routing/__init__.py +32 -0
- attune_llm/routing/model_router.py +362 -0
- attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
- attune_llm/security/PHASE2_COMPLETE.md +384 -0
- attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
- attune_llm/security/QUICK_REFERENCE.md +316 -0
- attune_llm/security/README.md +262 -0
- attune_llm/security/__init__.py +62 -0
- attune_llm/security/audit_logger.py +929 -0
- attune_llm/security/audit_logger_example.py +152 -0
- attune_llm/security/pii_scrubber.py +640 -0
- attune_llm/security/secrets_detector.py +678 -0
- attune_llm/security/secrets_detector_example.py +304 -0
- attune_llm/security/secure_memdocs.py +1192 -0
- attune_llm/security/secure_memdocs_example.py +278 -0
- attune_llm/session_status.py +745 -0
- attune_llm/state.py +246 -0
- attune_llm/utils/__init__.py +5 -0
- attune_llm/utils/tokens.py +349 -0
- attune_software/SOFTWARE_PLUGIN_README.md +57 -0
- attune_software/__init__.py +13 -0
- attune_software/cli/__init__.py +120 -0
- attune_software/cli/inspect.py +362 -0
- attune_software/cli.py +574 -0
- attune_software/plugin.py +188 -0
- workflow_scaffolding/__init__.py +11 -0
- workflow_scaffolding/__main__.py +12 -0
- workflow_scaffolding/cli.py +206 -0
- workflow_scaffolding/generator.py +265 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""Fallback and Resilience Policies for Multi-Model Workflows
|
|
2
|
+
|
|
3
|
+
Provides abstractions for handling LLM failures gracefully:
|
|
4
|
+
- FallbackPolicy: Define fallback chains for providers/tiers
|
|
5
|
+
- CircuitBreaker: Temporarily disable failing providers
|
|
6
|
+
- RetryPolicy: Configure retry behavior
|
|
7
|
+
|
|
8
|
+
Copyright 2025 Smart-AI-Memory
|
|
9
|
+
Licensed under Fair Source License 0.9
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
from .registry import get_model
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FallbackStrategy(Enum):
|
|
23
|
+
"""Strategies for selecting fallback models."""
|
|
24
|
+
|
|
25
|
+
# Try same tier with different provider
|
|
26
|
+
SAME_TIER_DIFFERENT_PROVIDER = "same_tier_different_provider"
|
|
27
|
+
|
|
28
|
+
# Try cheaper tier with same provider
|
|
29
|
+
CHEAPER_TIER_SAME_PROVIDER = "cheaper_tier_same_provider"
|
|
30
|
+
|
|
31
|
+
# Try different provider, any tier
|
|
32
|
+
DIFFERENT_PROVIDER_ANY_TIER = "different_provider_any_tier"
|
|
33
|
+
|
|
34
|
+
# Custom fallback chain
|
|
35
|
+
CUSTOM = "custom"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FallbackStep:
|
|
40
|
+
"""A single step in a fallback chain."""
|
|
41
|
+
|
|
42
|
+
provider: str
|
|
43
|
+
tier: str
|
|
44
|
+
description: str = ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def model_id(self) -> str:
|
|
48
|
+
"""Get the model ID for this step."""
|
|
49
|
+
model = get_model(self.provider, self.tier)
|
|
50
|
+
return model.id if model else ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class FallbackPolicy:
|
|
55
|
+
"""Policy for handling LLM failures with fallback chains.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> policy = FallbackPolicy(
|
|
59
|
+
... primary_provider="anthropic",
|
|
60
|
+
... primary_tier="capable",
|
|
61
|
+
... strategy=FallbackStrategy.SAME_TIER_DIFFERENT_PROVIDER,
|
|
62
|
+
... )
|
|
63
|
+
>>> chain = policy.get_fallback_chain()
|
|
64
|
+
>>> # Returns: [("openai", "capable"), ("ollama", "capable")]
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# Primary configuration
|
|
69
|
+
primary_provider: str = "anthropic"
|
|
70
|
+
primary_tier: str = "capable"
|
|
71
|
+
|
|
72
|
+
# Fallback configuration
|
|
73
|
+
strategy: FallbackStrategy = FallbackStrategy.SAME_TIER_DIFFERENT_PROVIDER
|
|
74
|
+
custom_chain: list[FallbackStep] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
# Retry configuration
|
|
77
|
+
max_retries: int = 2
|
|
78
|
+
retry_delay_ms: int = 1000
|
|
79
|
+
exponential_backoff: bool = True
|
|
80
|
+
|
|
81
|
+
# Timeout configuration
|
|
82
|
+
timeout_ms: int = 30000
|
|
83
|
+
|
|
84
|
+
def get_fallback_chain(self) -> list[FallbackStep]:
|
|
85
|
+
"""Get the fallback chain based on strategy.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of FallbackStep in order of preference
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
if self.strategy == FallbackStrategy.CUSTOM:
|
|
92
|
+
return self.custom_chain
|
|
93
|
+
|
|
94
|
+
chain: list[FallbackStep] = []
|
|
95
|
+
all_providers = ["anthropic"] # Anthropic-only as of v5.0.0
|
|
96
|
+
all_tiers = ["premium", "capable", "cheap"]
|
|
97
|
+
# Optimization: Cache tier index for O(1) lookup (vs O(n) .index() call)
|
|
98
|
+
tier_index_map = {tier: i for i, tier in enumerate(all_tiers)}
|
|
99
|
+
tier_index = tier_index_map.get(self.primary_tier, 1)
|
|
100
|
+
|
|
101
|
+
if self.strategy == FallbackStrategy.SAME_TIER_DIFFERENT_PROVIDER:
|
|
102
|
+
# Try same tier with other providers
|
|
103
|
+
for provider in all_providers:
|
|
104
|
+
if provider != self.primary_provider:
|
|
105
|
+
chain.append(
|
|
106
|
+
FallbackStep(
|
|
107
|
+
provider=provider,
|
|
108
|
+
tier=self.primary_tier,
|
|
109
|
+
description=f"Same tier ({self.primary_tier}) on {provider}",
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
elif self.strategy == FallbackStrategy.CHEAPER_TIER_SAME_PROVIDER:
|
|
114
|
+
# Try cheaper tiers with same provider
|
|
115
|
+
for tier in all_tiers[tier_index + 1 :]:
|
|
116
|
+
chain.append(
|
|
117
|
+
FallbackStep(
|
|
118
|
+
provider=self.primary_provider,
|
|
119
|
+
tier=tier,
|
|
120
|
+
description=f"Cheaper tier ({tier}) on {self.primary_provider}",
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
elif self.strategy == FallbackStrategy.DIFFERENT_PROVIDER_ANY_TIER:
|
|
125
|
+
# Try other providers, preferring same tier then cheaper
|
|
126
|
+
for provider in all_providers:
|
|
127
|
+
if provider != self.primary_provider:
|
|
128
|
+
# Try same tier first
|
|
129
|
+
chain.append(
|
|
130
|
+
FallbackStep(
|
|
131
|
+
provider=provider,
|
|
132
|
+
tier=self.primary_tier,
|
|
133
|
+
description=f"{self.primary_tier} on {provider}",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
# Then cheaper tiers
|
|
137
|
+
for tier in all_tiers[tier_index + 1 :]:
|
|
138
|
+
chain.append(
|
|
139
|
+
FallbackStep(
|
|
140
|
+
provider=provider,
|
|
141
|
+
tier=tier,
|
|
142
|
+
description=f"{tier} on {provider}",
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return chain
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class CircuitBreakerState:
|
|
151
|
+
"""State of a circuit breaker for a provider."""
|
|
152
|
+
|
|
153
|
+
failure_count: int = 0
|
|
154
|
+
last_failure: datetime | None = None
|
|
155
|
+
is_open: bool = False
|
|
156
|
+
opened_at: datetime | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CircuitBreaker:
|
|
160
|
+
"""Circuit breaker to temporarily disable failing providers.
|
|
161
|
+
|
|
162
|
+
Prevents cascading failures by stopping calls to providers that
|
|
163
|
+
are experiencing issues. Tracks state per provider:tier combination
|
|
164
|
+
for fine-grained control (e.g., Opus rate-limited shouldn't block Haiku).
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
>>> breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=60)
|
|
168
|
+
>>> if breaker.is_available("anthropic", "capable"):
|
|
169
|
+
... try:
|
|
170
|
+
... response = call_llm(...)
|
|
171
|
+
... breaker.record_success("anthropic", "capable")
|
|
172
|
+
... except Exception as e:
|
|
173
|
+
... breaker.record_failure("anthropic", "capable")
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
failure_threshold: int = 5,
|
|
180
|
+
recovery_timeout_seconds: int = 60,
|
|
181
|
+
half_open_calls: int = 1,
|
|
182
|
+
):
|
|
183
|
+
"""Initialize circuit breaker.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
failure_threshold: Failures before opening circuit
|
|
187
|
+
recovery_timeout_seconds: Time before trying again
|
|
188
|
+
half_open_calls: Calls to allow in half-open state
|
|
189
|
+
|
|
190
|
+
"""
|
|
191
|
+
self.failure_threshold = failure_threshold
|
|
192
|
+
self.recovery_timeout = timedelta(seconds=recovery_timeout_seconds)
|
|
193
|
+
self.half_open_calls = half_open_calls
|
|
194
|
+
self._states: dict[str, CircuitBreakerState] = {}
|
|
195
|
+
|
|
196
|
+
def _get_key(self, provider: str, tier: str | None = None) -> str:
|
|
197
|
+
"""Get the state key for a provider:tier combination."""
|
|
198
|
+
if tier:
|
|
199
|
+
return f"{provider}:{tier}"
|
|
200
|
+
return provider
|
|
201
|
+
|
|
202
|
+
def _get_state(self, provider: str, tier: str | None = None) -> CircuitBreakerState:
|
|
203
|
+
"""Get or create state for a provider:tier combination."""
|
|
204
|
+
key = self._get_key(provider, tier)
|
|
205
|
+
if key not in self._states:
|
|
206
|
+
self._states[key] = CircuitBreakerState()
|
|
207
|
+
return self._states[key]
|
|
208
|
+
|
|
209
|
+
def is_available(self, provider: str, tier: str | None = None) -> bool:
|
|
210
|
+
"""Check if a provider:tier is available.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
provider: Provider to check
|
|
214
|
+
tier: Optional tier (if None, checks provider-level)
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True if provider:tier can be called
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
state = self._get_state(provider, tier)
|
|
221
|
+
|
|
222
|
+
if not state.is_open:
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
# Check if recovery timeout has passed
|
|
226
|
+
if state.opened_at:
|
|
227
|
+
time_since_open = datetime.now() - state.opened_at
|
|
228
|
+
if time_since_open >= self.recovery_timeout:
|
|
229
|
+
# Half-open: allow limited calls
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
def record_success(self, provider: str, tier: str | None = None) -> None:
|
|
235
|
+
"""Record a successful call.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
provider: Provider that succeeded
|
|
239
|
+
tier: Optional tier
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
state = self._get_state(provider, tier)
|
|
243
|
+
|
|
244
|
+
# Reset on success
|
|
245
|
+
state.failure_count = 0
|
|
246
|
+
state.is_open = False
|
|
247
|
+
state.opened_at = None
|
|
248
|
+
|
|
249
|
+
def record_failure(self, provider: str, tier: str | None = None) -> None:
|
|
250
|
+
"""Record a failed call.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
provider: Provider that failed
|
|
254
|
+
tier: Optional tier
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
state = self._get_state(provider, tier)
|
|
258
|
+
|
|
259
|
+
state.failure_count += 1
|
|
260
|
+
state.last_failure = datetime.now()
|
|
261
|
+
|
|
262
|
+
if state.failure_count >= self.failure_threshold:
|
|
263
|
+
state.is_open = True
|
|
264
|
+
state.opened_at = datetime.now()
|
|
265
|
+
|
|
266
|
+
def get_status(self) -> dict[str, dict[str, Any]]:
|
|
267
|
+
"""Get status of all tracked providers."""
|
|
268
|
+
return {
|
|
269
|
+
provider: {
|
|
270
|
+
"failure_count": state.failure_count,
|
|
271
|
+
"is_open": state.is_open,
|
|
272
|
+
"last_failure": state.last_failure.isoformat() if state.last_failure else None,
|
|
273
|
+
"opened_at": state.opened_at.isoformat() if state.opened_at else None,
|
|
274
|
+
}
|
|
275
|
+
for provider, state in self._states.items()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
def reset(self, provider: str | None = None, tier: str | None = None) -> None:
|
|
279
|
+
"""Reset circuit breaker state.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
provider: Provider to reset (all if None)
|
|
283
|
+
tier: Tier to reset (provider-level if None)
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
if provider:
|
|
287
|
+
key = self._get_key(provider, tier)
|
|
288
|
+
if key in self._states:
|
|
289
|
+
self._states[key] = CircuitBreakerState()
|
|
290
|
+
else:
|
|
291
|
+
self._states.clear()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class RetryPolicy:
|
|
296
|
+
"""Policy for retrying failed LLM calls.
|
|
297
|
+
|
|
298
|
+
Configures how many times to retry and with what delays.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
max_retries: int = 3
|
|
302
|
+
initial_delay_ms: int = 1000
|
|
303
|
+
max_delay_ms: int = 30000
|
|
304
|
+
exponential_backoff: bool = True
|
|
305
|
+
backoff_multiplier: float = 2.0
|
|
306
|
+
retry_on_errors: list[str] = field(
|
|
307
|
+
default_factory=lambda: [
|
|
308
|
+
"rate_limit",
|
|
309
|
+
"timeout",
|
|
310
|
+
"server_error",
|
|
311
|
+
"connection_error",
|
|
312
|
+
],
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def get_delay_ms(self, attempt: int) -> int:
|
|
316
|
+
"""Get delay before retry attempt.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
attempt: Current attempt number (1-indexed)
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Delay in milliseconds
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
if not self.exponential_backoff:
|
|
326
|
+
return self.initial_delay_ms
|
|
327
|
+
|
|
328
|
+
delay = self.initial_delay_ms * (self.backoff_multiplier ** (attempt - 1))
|
|
329
|
+
return min(int(delay), self.max_delay_ms)
|
|
330
|
+
|
|
331
|
+
def should_retry(self, error_type: str, attempt: int) -> bool:
|
|
332
|
+
"""Check if should retry for this error.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
error_type: Type of error encountered
|
|
336
|
+
attempt: Current attempt number
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
True if should retry
|
|
340
|
+
|
|
341
|
+
"""
|
|
342
|
+
if attempt >= self.max_retries:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
return error_type in self.retry_on_errors
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class AllProvidersFailedError(Exception):
|
|
349
|
+
"""Raised when all fallback providers have failed."""
|
|
350
|
+
|
|
351
|
+
def __init__(self, message: str, attempts: list[dict[str, Any]]):
|
|
352
|
+
super().__init__(message)
|
|
353
|
+
self.attempts = attempts
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class ResilientExecutor:
|
|
357
|
+
"""Wrapper that adds resilience to LLM execution.
|
|
358
|
+
|
|
359
|
+
Combines fallback policies, circuit breakers, and retry logic.
|
|
360
|
+
Implements the LLMExecutor protocol by wrapping another executor.
|
|
361
|
+
|
|
362
|
+
Example:
|
|
363
|
+
>>> from attune.models.empathy_executor import EmpathyLLMExecutor
|
|
364
|
+
>>> base_executor = EmpathyLLMExecutor(provider="anthropic")
|
|
365
|
+
>>> resilient = ResilientExecutor(executor=base_executor)
|
|
366
|
+
>>> response = await resilient.run("summarize", "Summarize this...")
|
|
367
|
+
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def __init__(
|
|
371
|
+
self,
|
|
372
|
+
executor: Any | None = None,
|
|
373
|
+
fallback_policy: FallbackPolicy | None = None,
|
|
374
|
+
circuit_breaker: CircuitBreaker | None = None,
|
|
375
|
+
retry_policy: RetryPolicy | None = None,
|
|
376
|
+
):
|
|
377
|
+
"""Initialize resilient executor.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
executor: Inner LLMExecutor to wrap
|
|
381
|
+
fallback_policy: Fallback configuration
|
|
382
|
+
circuit_breaker: Circuit breaker instance
|
|
383
|
+
retry_policy: Retry configuration
|
|
384
|
+
|
|
385
|
+
"""
|
|
386
|
+
self._executor = executor
|
|
387
|
+
self.fallback_policy = fallback_policy or FallbackPolicy()
|
|
388
|
+
self.circuit_breaker = circuit_breaker or CircuitBreaker()
|
|
389
|
+
self.retry_policy = retry_policy or RetryPolicy()
|
|
390
|
+
|
|
391
|
+
async def run(
|
|
392
|
+
self,
|
|
393
|
+
task_type: str,
|
|
394
|
+
prompt: str,
|
|
395
|
+
system: str | None = None,
|
|
396
|
+
context: Any | None = None,
|
|
397
|
+
**kwargs: Any,
|
|
398
|
+
) -> Any:
|
|
399
|
+
"""Execute LLM call with retry and fallback support.
|
|
400
|
+
|
|
401
|
+
Implements the LLMExecutor protocol. Uses per-call policies from
|
|
402
|
+
context.metadata if provided.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
task_type: Type of task for routing
|
|
406
|
+
prompt: The user prompt
|
|
407
|
+
system: Optional system prompt
|
|
408
|
+
context: Optional ExecutionContext (can contain retry_policy, fallback_policy)
|
|
409
|
+
**kwargs: Additional arguments
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
LLMResponse from the wrapped executor
|
|
413
|
+
|
|
414
|
+
"""
|
|
415
|
+
if self._executor is None:
|
|
416
|
+
raise RuntimeError("ResilientExecutor requires an inner executor")
|
|
417
|
+
|
|
418
|
+
# Allow per-call policy overrides via context.metadata
|
|
419
|
+
retry_policy = self.retry_policy
|
|
420
|
+
fallback_policy = self.fallback_policy
|
|
421
|
+
|
|
422
|
+
if context and hasattr(context, "metadata"):
|
|
423
|
+
if "retry_policy" in context.metadata:
|
|
424
|
+
retry_policy = context.metadata["retry_policy"]
|
|
425
|
+
if "fallback_policy" in context.metadata:
|
|
426
|
+
fallback_policy = context.metadata["fallback_policy"]
|
|
427
|
+
|
|
428
|
+
# Build execution chain: primary + fallbacks
|
|
429
|
+
chain = [
|
|
430
|
+
FallbackStep(
|
|
431
|
+
provider=fallback_policy.primary_provider,
|
|
432
|
+
tier=fallback_policy.primary_tier,
|
|
433
|
+
description="Primary",
|
|
434
|
+
),
|
|
435
|
+
] + fallback_policy.get_fallback_chain()
|
|
436
|
+
|
|
437
|
+
attempts: list[dict[str, Any]] = []
|
|
438
|
+
last_error: Exception | None = None
|
|
439
|
+
total_retries = 0 # Track total retry count across all attempts
|
|
440
|
+
|
|
441
|
+
for step in chain:
|
|
442
|
+
# Check circuit breaker (per provider:tier)
|
|
443
|
+
if not self.circuit_breaker.is_available(step.provider, step.tier):
|
|
444
|
+
attempts.append(
|
|
445
|
+
{
|
|
446
|
+
"provider": step.provider,
|
|
447
|
+
"tier": step.tier,
|
|
448
|
+
"skipped": True,
|
|
449
|
+
"reason": "circuit_breaker_open",
|
|
450
|
+
"circuit_breaker_state": "open",
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
# Try with retries
|
|
456
|
+
for attempt_num in range(1, retry_policy.max_retries + 1):
|
|
457
|
+
try:
|
|
458
|
+
# Update context with current provider/tier hints
|
|
459
|
+
if context and hasattr(context, "provider_hint"):
|
|
460
|
+
context.provider_hint = step.provider
|
|
461
|
+
if context and hasattr(context, "tier_hint"):
|
|
462
|
+
context.tier_hint = step.tier
|
|
463
|
+
|
|
464
|
+
response = await self._executor.run(
|
|
465
|
+
task_type=task_type,
|
|
466
|
+
prompt=prompt,
|
|
467
|
+
system=system,
|
|
468
|
+
context=context,
|
|
469
|
+
**kwargs,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Success - record and return
|
|
473
|
+
self.circuit_breaker.record_success(step.provider, step.tier)
|
|
474
|
+
|
|
475
|
+
# Add resilience metadata to response
|
|
476
|
+
if hasattr(response, "metadata"):
|
|
477
|
+
response.metadata["fallback_used"] = step.description != "Primary"
|
|
478
|
+
response.metadata["attempts"] = attempts
|
|
479
|
+
response.metadata["retry_count"] = total_retries
|
|
480
|
+
response.metadata["circuit_breaker_state"] = "closed"
|
|
481
|
+
response.metadata["original_provider"] = fallback_policy.primary_provider
|
|
482
|
+
response.metadata["original_tier"] = fallback_policy.primary_tier
|
|
483
|
+
if step.description != "Primary":
|
|
484
|
+
response.metadata["fallback_chain"] = [
|
|
485
|
+
f"{a['provider']}:{a['tier']}" for a in attempts
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
return response
|
|
489
|
+
|
|
490
|
+
except Exception as e:
|
|
491
|
+
last_error = e
|
|
492
|
+
error_type = self._classify_error(e)
|
|
493
|
+
total_retries += 1 # Increment retry counter
|
|
494
|
+
|
|
495
|
+
if retry_policy.should_retry(error_type, attempt_num):
|
|
496
|
+
delay = retry_policy.get_delay_ms(attempt_num)
|
|
497
|
+
time.sleep(delay / 1000)
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
# Record failure and move to next fallback
|
|
501
|
+
self.circuit_breaker.record_failure(step.provider, step.tier)
|
|
502
|
+
attempts.append(
|
|
503
|
+
{
|
|
504
|
+
"provider": step.provider,
|
|
505
|
+
"tier": step.tier,
|
|
506
|
+
"skipped": False,
|
|
507
|
+
"error": str(e),
|
|
508
|
+
"error_type": error_type,
|
|
509
|
+
"attempt": attempt_num,
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
break
|
|
513
|
+
|
|
514
|
+
# All fallbacks exhausted
|
|
515
|
+
raise AllProvidersFailedError(
|
|
516
|
+
f"All fallback options exhausted. Last error: {last_error}",
|
|
517
|
+
attempts=attempts,
|
|
518
|
+
) from last_error
|
|
519
|
+
|
|
520
|
+
def get_model_for_task(self, task_type: str) -> str:
|
|
521
|
+
"""Delegate to inner executor."""
|
|
522
|
+
if self._executor and hasattr(self._executor, "get_model_for_task"):
|
|
523
|
+
result: str = cast("str", self._executor.get_model_for_task(task_type))
|
|
524
|
+
return result
|
|
525
|
+
return ""
|
|
526
|
+
|
|
527
|
+
def estimate_cost(
|
|
528
|
+
self,
|
|
529
|
+
task_type: str,
|
|
530
|
+
input_tokens: int,
|
|
531
|
+
output_tokens: int,
|
|
532
|
+
) -> float:
|
|
533
|
+
"""Delegate to inner executor."""
|
|
534
|
+
if self._executor and hasattr(self._executor, "estimate_cost"):
|
|
535
|
+
result: float = cast(
|
|
536
|
+
"float",
|
|
537
|
+
self._executor.estimate_cost(task_type, input_tokens, output_tokens),
|
|
538
|
+
)
|
|
539
|
+
return result
|
|
540
|
+
return 0.0
|
|
541
|
+
|
|
542
|
+
async def execute_with_fallback(
|
|
543
|
+
self,
|
|
544
|
+
call_fn: Callable,
|
|
545
|
+
*args: Any,
|
|
546
|
+
**kwargs: Any,
|
|
547
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
548
|
+
"""Execute LLM call with fallback support (legacy API).
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
call_fn: Async function to call (takes provider, model as kwargs)
|
|
552
|
+
*args: Positional arguments for call_fn
|
|
553
|
+
**kwargs: Keyword arguments for call_fn
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
Tuple of (result, metadata) where metadata includes fallback info
|
|
557
|
+
|
|
558
|
+
"""
|
|
559
|
+
metadata: dict[str, Any] = {
|
|
560
|
+
"fallback_used": False,
|
|
561
|
+
"fallback_chain": [],
|
|
562
|
+
"attempts": 0,
|
|
563
|
+
"original_provider": self.fallback_policy.primary_provider,
|
|
564
|
+
"original_model": None,
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
# Build execution chain: primary + fallbacks
|
|
568
|
+
chain = [
|
|
569
|
+
FallbackStep(
|
|
570
|
+
provider=self.fallback_policy.primary_provider,
|
|
571
|
+
tier=self.fallback_policy.primary_tier,
|
|
572
|
+
description="Primary",
|
|
573
|
+
),
|
|
574
|
+
] + self.fallback_policy.get_fallback_chain()
|
|
575
|
+
|
|
576
|
+
last_error: Exception | None = None
|
|
577
|
+
|
|
578
|
+
for step in chain:
|
|
579
|
+
# Check circuit breaker (per provider:tier)
|
|
580
|
+
if not self.circuit_breaker.is_available(step.provider, step.tier):
|
|
581
|
+
metadata["fallback_chain"].append(
|
|
582
|
+
{
|
|
583
|
+
"provider": step.provider,
|
|
584
|
+
"tier": step.tier,
|
|
585
|
+
"skipped": True,
|
|
586
|
+
"reason": "circuit_breaker_open",
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
# Try with retries
|
|
592
|
+
for attempt in range(1, self.retry_policy.max_retries + 1):
|
|
593
|
+
metadata["attempts"] += 1
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
result = await call_fn(
|
|
597
|
+
*args,
|
|
598
|
+
provider=step.provider,
|
|
599
|
+
model=step.model_id,
|
|
600
|
+
**kwargs,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
# Success
|
|
604
|
+
self.circuit_breaker.record_success(step.provider, step.tier)
|
|
605
|
+
|
|
606
|
+
if step.description != "Primary":
|
|
607
|
+
metadata["fallback_used"] = True
|
|
608
|
+
|
|
609
|
+
metadata["final_provider"] = step.provider
|
|
610
|
+
metadata["final_tier"] = step.tier
|
|
611
|
+
metadata["final_model"] = step.model_id
|
|
612
|
+
|
|
613
|
+
return result, metadata
|
|
614
|
+
|
|
615
|
+
except Exception as e:
|
|
616
|
+
last_error = e
|
|
617
|
+
error_type = self._classify_error(e)
|
|
618
|
+
|
|
619
|
+
if self.retry_policy.should_retry(error_type, attempt):
|
|
620
|
+
delay = self.retry_policy.get_delay_ms(attempt)
|
|
621
|
+
time.sleep(delay / 1000)
|
|
622
|
+
continue
|
|
623
|
+
|
|
624
|
+
# Record failure and move to next fallback
|
|
625
|
+
self.circuit_breaker.record_failure(step.provider, step.tier)
|
|
626
|
+
metadata["fallback_chain"].append(
|
|
627
|
+
{
|
|
628
|
+
"provider": step.provider,
|
|
629
|
+
"tier": step.tier,
|
|
630
|
+
"skipped": False,
|
|
631
|
+
"error": str(e),
|
|
632
|
+
"error_type": error_type,
|
|
633
|
+
},
|
|
634
|
+
)
|
|
635
|
+
break
|
|
636
|
+
|
|
637
|
+
# All fallbacks exhausted
|
|
638
|
+
raise AllProvidersFailedError(
|
|
639
|
+
f"All fallback options exhausted. Last error: {last_error}",
|
|
640
|
+
attempts=metadata["fallback_chain"],
|
|
641
|
+
) from last_error
|
|
642
|
+
|
|
643
|
+
def _classify_error(self, error: Exception) -> str:
|
|
644
|
+
"""Classify an error for retry decisions."""
|
|
645
|
+
error_str = str(error).lower()
|
|
646
|
+
|
|
647
|
+
if "rate" in error_str or "limit" in error_str:
|
|
648
|
+
return "rate_limit"
|
|
649
|
+
if "timeout" in error_str:
|
|
650
|
+
return "timeout"
|
|
651
|
+
if "connection" in error_str:
|
|
652
|
+
return "connection_error"
|
|
653
|
+
if "500" in error_str or "502" in error_str or "503" in error_str:
|
|
654
|
+
return "server_error"
|
|
655
|
+
return "unknown"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# Default policies
|
|
659
|
+
DEFAULT_FALLBACK_POLICY = FallbackPolicy(
|
|
660
|
+
primary_provider="anthropic",
|
|
661
|
+
primary_tier="capable",
|
|
662
|
+
strategy=FallbackStrategy.SAME_TIER_DIFFERENT_PROVIDER,
|
|
663
|
+
max_retries=2,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
# Intelligent Sonnet 4.5 → Opus 4.5 fallback policy
|
|
667
|
+
# Tries Sonnet 4.5 first, then upgrades to Opus 4.5 if needed
|
|
668
|
+
# Tracks cost savings when Sonnet succeeds (saves 80% vs always using Opus)
|
|
669
|
+
SONNET_TO_OPUS_FALLBACK = FallbackPolicy(
|
|
670
|
+
primary_provider="anthropic",
|
|
671
|
+
primary_tier="capable", # Sonnet 4.5
|
|
672
|
+
strategy=FallbackStrategy.CUSTOM,
|
|
673
|
+
custom_chain=[
|
|
674
|
+
FallbackStep(
|
|
675
|
+
provider="anthropic",
|
|
676
|
+
tier="premium", # Opus 4.5
|
|
677
|
+
description="Upgraded to Opus 4.5 for complex reasoning",
|
|
678
|
+
),
|
|
679
|
+
],
|
|
680
|
+
max_retries=1, # Only retry once before upgrading to Opus
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
DEFAULT_RETRY_POLICY = RetryPolicy(
|
|
684
|
+
max_retries=3,
|
|
685
|
+
initial_delay_ms=1000,
|
|
686
|
+
exponential_backoff=True,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
class TierFallbackHelper:
|
|
691
|
+
"""Helper class for simple tier-based fallback logic.
|
|
692
|
+
|
|
693
|
+
Provides convenience methods for Sprint 1 tests while preserving
|
|
694
|
+
the sophisticated FallbackPolicy for production use.
|
|
695
|
+
|
|
696
|
+
Example:
|
|
697
|
+
>>> TierFallbackHelper.get_next_tier("cheap")
|
|
698
|
+
'capable'
|
|
699
|
+
>>> TierFallbackHelper.should_fallback(TimeoutError(), "cheap")
|
|
700
|
+
True
|
|
701
|
+
>>> TierFallbackHelper.should_fallback(ValueError(), "premium")
|
|
702
|
+
False
|
|
703
|
+
|
|
704
|
+
"""
|
|
705
|
+
|
|
706
|
+
TIER_PROGRESSION = {
|
|
707
|
+
"cheap": "capable",
|
|
708
|
+
"capable": "premium",
|
|
709
|
+
"premium": None,
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
@classmethod
|
|
713
|
+
def get_next_tier(cls, current_tier: str) -> str | None:
|
|
714
|
+
"""Get next tier in fallback chain.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
current_tier: Current tier name (cheap, capable, premium)
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
Next tier name, or None if at highest tier
|
|
721
|
+
|
|
722
|
+
Example:
|
|
723
|
+
>>> TierFallbackHelper.get_next_tier("cheap")
|
|
724
|
+
'capable'
|
|
725
|
+
>>> TierFallbackHelper.get_next_tier("premium")
|
|
726
|
+
None
|
|
727
|
+
|
|
728
|
+
"""
|
|
729
|
+
return cls.TIER_PROGRESSION.get(current_tier)
|
|
730
|
+
|
|
731
|
+
@classmethod
|
|
732
|
+
def should_fallback(cls, error: Exception, tier: str) -> bool:
|
|
733
|
+
"""Determine if fallback should be attempted.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
error: Exception that was raised
|
|
737
|
+
tier: Current tier that failed
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
True if fallback should be attempted, False otherwise
|
|
741
|
+
|
|
742
|
+
Logic:
|
|
743
|
+
- Never fallback from premium tier (highest tier)
|
|
744
|
+
- Fallback for network/connection errors (TimeoutError, ConnectionError, OSError)
|
|
745
|
+
- Don't fallback for logic errors (ValueError, TypeError, etc.)
|
|
746
|
+
|
|
747
|
+
Example:
|
|
748
|
+
>>> TierFallbackHelper.should_fallback(TimeoutError(), "cheap")
|
|
749
|
+
True
|
|
750
|
+
>>> TierFallbackHelper.should_fallback(ValueError(), "cheap")
|
|
751
|
+
False
|
|
752
|
+
>>> TierFallbackHelper.should_fallback(TimeoutError(), "premium")
|
|
753
|
+
False
|
|
754
|
+
|
|
755
|
+
"""
|
|
756
|
+
# Never fallback from premium tier
|
|
757
|
+
if tier == "premium":
|
|
758
|
+
return False
|
|
759
|
+
|
|
760
|
+
# Fallback for connection/network errors
|
|
761
|
+
fallback_errors = (TimeoutError, ConnectionError, OSError)
|
|
762
|
+
return isinstance(error, fallback_errors)
|