coderouter-cli 2.3.0a3__tar.gz → 2.4.0__tar.gz

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 (188) hide show
  1. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/.gitignore +5 -0
  2. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/CHANGELOG.md +121 -0
  3. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/PKG-INFO +2 -2
  4. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/README.en.md +1 -1
  5. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/README.md +1 -1
  6. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/cli.py +31 -0
  7. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/config/schemas.py +22 -0
  8. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/__init__.py +2 -0
  9. coderouter_cli-2.4.0/coderouter/guards/_fingerprint.py +125 -0
  10. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/drift_detection.py +55 -0
  11. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/plugins/__init__.py +5 -8
  12. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/routing/fallback.py +52 -4
  13. coderouter_cli-2.4.0/coderouter/state/__init__.py +19 -0
  14. coderouter_cli-2.4.0/coderouter/state/suggest_rules.py +413 -0
  15. coderouter_cli-2.4.0/docs/verify-ollama-0.23.1.md +243 -0
  16. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/pyproject.toml +1 -1
  17. coderouter_cli-2.4.0/scripts/verify-providers.yaml +161 -0
  18. coderouter_cli-2.4.0/scripts/verify_ollama_0_23.py +680 -0
  19. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_plugins_integration.py +13 -118
  20. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_plugins_loader.py +3 -7
  21. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_plugins_registry.py +1 -0
  22. coderouter_cli-2.3.0a3/coderouter/state/__init__.py +0 -15
  23. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/LICENSE +0 -0
  24. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/__init__.py +0 -0
  25. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/__main__.py +0 -0
  26. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/adapters/__init__.py +0 -0
  27. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/adapters/anthropic_native.py +0 -0
  28. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/adapters/base.py +0 -0
  29. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/adapters/openai_compat.py +0 -0
  30. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/adapters/registry.py +0 -0
  31. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/cli_stats.py +0 -0
  32. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/config/__init__.py +0 -0
  33. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/config/capability_registry.py +0 -0
  34. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/config/env_file.py +0 -0
  35. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/config/loader.py +0 -0
  36. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/cost.py +0 -0
  37. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/data/__init__.py +0 -0
  38. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/data/model-capabilities.yaml +0 -0
  39. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/doctor.py +0 -0
  40. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/doctor_apply.py +0 -0
  41. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/env_security.py +0 -0
  42. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/errors.py +0 -0
  43. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/backend_health.py +0 -0
  44. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/context_budget.py +0 -0
  45. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/continuous_probe.py +0 -0
  46. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/drift_actions.py +0 -0
  47. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/memory_pressure.py +0 -0
  48. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/self_healing.py +0 -0
  49. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/guards/tool_loop.py +0 -0
  50. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/ingress/__init__.py +0 -0
  51. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/ingress/anthropic_routes.py +0 -0
  52. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/ingress/app.py +0 -0
  53. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/ingress/dashboard_routes.py +0 -0
  54. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/ingress/metrics_routes.py +0 -0
  55. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/ingress/openai_routes.py +0 -0
  56. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/logging.py +0 -0
  57. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/metrics/__init__.py +0 -0
  58. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/metrics/collector.py +0 -0
  59. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/metrics/prometheus.py +0 -0
  60. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/output_filters.py +0 -0
  61. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/plugins/base.py +0 -0
  62. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/plugins/loader.py +0 -0
  63. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/plugins/registry.py +0 -0
  64. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/routing/__init__.py +0 -0
  65. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/routing/adaptive.py +0 -0
  66. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/routing/auto_router.py +0 -0
  67. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/routing/budget.py +0 -0
  68. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/routing/capability.py +0 -0
  69. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/state/audit_log.py +0 -0
  70. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/state/replay.py +0 -0
  71. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/state/request_log.py +0 -0
  72. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/state/store.py +0 -0
  73. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/token_estimation.py +0 -0
  74. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/translation/__init__.py +0 -0
  75. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/translation/anthropic.py +0 -0
  76. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/translation/convert.py +0 -0
  77. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/coderouter/translation/tool_repair.py +0 -0
  78. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/architecture.md +0 -0
  79. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/assets/dashboard-demo.png +0 -0
  80. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/context-budget.md +0 -0
  81. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/continuous-probing.md +0 -0
  82. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/designs/v1.5-dashboard-mockup.html +0 -0
  83. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/designs/v1.6-auto-router-verification.md +0 -0
  84. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/designs/v1.6-auto-router.md +0 -0
  85. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/drift-detection.md +0 -0
  86. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/free-tier-guide.en.md +0 -0
  87. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/free-tier-guide.md +0 -0
  88. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/gguf_dl.md +0 -0
  89. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/hf-ollama-models.md +0 -0
  90. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/llamacpp-direct.en.md +0 -0
  91. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/llamacpp-direct.md +0 -0
  92. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/lmstudio-direct.en.md +0 -0
  93. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/lmstudio-direct.md +0 -0
  94. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/openrouter-roster/CHANGES.md +0 -0
  95. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/openrouter-roster/README.md +0 -0
  96. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/openrouter-roster/latest.json +0 -0
  97. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/partial-stitch.md +0 -0
  98. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/quickstart.en.md +0 -0
  99. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/quickstart.md +0 -0
  100. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v0.4.md +0 -0
  101. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v0.5-verify.md +0 -0
  102. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v0.5.md +0 -0
  103. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v0.6.md +0 -0
  104. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v0.7.md +0 -0
  105. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v1.0-verify.md +0 -0
  106. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/retrospectives/v1.0.md +0 -0
  107. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/security.en.md +0 -0
  108. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/security.md +0 -0
  109. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/troubleshooting.en.md +0 -0
  110. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/troubleshooting.md +0 -0
  111. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/usage-guide.en.md +0 -0
  112. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/usage-guide.md +0 -0
  113. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/when-do-i-need-coderouter.en.md +0 -0
  114. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/docs/when-do-i-need-coderouter.md +0 -0
  115. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/.env.example +0 -0
  116. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.auto-custom.yaml +0 -0
  117. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.auto.yaml +0 -0
  118. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.note-2026.yaml +0 -0
  119. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.nvidia-nim.yaml +0 -0
  120. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.raspberrypi.yaml +0 -0
  121. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.v2-context-budget.yaml +0 -0
  122. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/examples/providers.yaml +0 -0
  123. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/scripts/demo_traffic.sh +0 -0
  124. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/scripts/openrouter_roster_diff.py +0 -0
  125. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/scripts/smoke_v2_2.sh +0 -0
  126. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/scripts/verify_v0_5.sh +0 -0
  127. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/scripts/verify_v1_0.sh +0 -0
  128. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/__init__.py +0 -0
  129. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/conftest.py +0 -0
  130. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_adapter_anthropic.py +0 -0
  131. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_audit_log.py +0 -0
  132. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_auto_router.py +0 -0
  133. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_backend_health.py +0 -0
  134. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_budget.py +0 -0
  135. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_capability.py +0 -0
  136. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_capability_degraded_payload.py +0 -0
  137. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_capability_registry.py +0 -0
  138. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_capability_registry_cache_control.py +0 -0
  139. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_claude_code_suitability.py +0 -0
  140. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_cli.py +0 -0
  141. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_cli_stats.py +0 -0
  142. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_config.py +0 -0
  143. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_context_budget.py +0 -0
  144. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_continuous_probe.py +0 -0
  145. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_dashboard_endpoint.py +0 -0
  146. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_doctor.py +0 -0
  147. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_doctor_apply.py +0 -0
  148. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_doctor_cache_probe.py +0 -0
  149. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_drift_actions.py +0 -0
  150. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_drift_detection.py +0 -0
  151. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_drift_detection_integration.py +0 -0
  152. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_env_file.py +0 -0
  153. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_env_security.py +0 -0
  154. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_errors.py +0 -0
  155. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_examples_yaml.py +0 -0
  156. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback.py +0 -0
  157. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback_anthropic.py +0 -0
  158. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback_cache_control.py +0 -0
  159. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback_cache_observed.py +0 -0
  160. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback_misconfig_warn.py +0 -0
  161. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback_paid_gate.py +0 -0
  162. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_fallback_thinking.py +0 -0
  163. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_guards_tool_loop.py +0 -0
  164. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_ingress_anthropic.py +0 -0
  165. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_ingress_profile.py +0 -0
  166. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_memory_pressure.py +0 -0
  167. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_cache.py +0 -0
  168. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_collector.py +0 -0
  169. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_cost.py +0 -0
  170. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_endpoint.py +0 -0
  171. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_jsonl.py +0 -0
  172. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_prometheus.py +0 -0
  173. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_metrics_prometheus_cache.py +0 -0
  174. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_openai_compat.py +0 -0
  175. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_openrouter_roster_diff.py +0 -0
  176. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_output_filters.py +0 -0
  177. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_output_filters_adapters.py +0 -0
  178. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_partial_stitch.py +0 -0
  179. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_reasoning_strip.py +0 -0
  180. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_request_log.py +0 -0
  181. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_routing_adaptive.py +0 -0
  182. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_self_healing.py +0 -0
  183. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_setup_sh.py +0 -0
  184. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_state_store.py +0 -0
  185. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_token_estimation.py +0 -0
  186. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_tool_repair.py +0 -0
  187. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_translation_anthropic.py +0 -0
  188. {coderouter_cli-2.3.0a3 → coderouter_cli-2.4.0}/tests/test_translation_reverse.py +0 -0
@@ -32,6 +32,11 @@ env/
32
32
  ENV/
33
33
  .python-version-local
34
34
 
35
+ # ============================================================
36
+ # _OUTPUTS
37
+ # ============================================================
38
+ _OUTPUTS/
39
+
35
40
  # uv
36
41
  # Note: uv.lock SHOULD be committed (per plan.md §5.4)
37
42
  # Do NOT add uv.lock here.
@@ -6,6 +6,127 @@ versioning follows [SemVer](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [v2.4.0] — 2026-05-15 (Goal-session awareness — P1-4/5/6)
10
+
11
+ Stable release following v2.3.0a4. Promotes the Plugin SDK to stable,
12
+ adds three goal-session features, and ships a rule-suggestion CLI.
13
+
14
+ ### Added
15
+
16
+ - **`coderouter/guards/_fingerprint.py`** (P1-4): Response fingerprinting
17
+ helper. `fingerprint_response(text)` returns a 12-hex SHA-256 digest
18
+ of the top-N content words (stop-word-filtered, order-independent).
19
+ Used by the new `goal_progress_stall` drift signal to detect when a
20
+ model repeats itself without making progress.
21
+
22
+ - **Signal 6 — `goal_progress_stall`** (`drift_detection.py`, P1-4):
23
+ Sixth drift signal added to `detect_drift()`. Fires (mild) when the
24
+ fraction of fingerprinted responses that repeat an already-seen
25
+ fingerprint exceeds `repetition_rate_threshold` (default 0.4).
26
+ Requires `response_fingerprint` to be populated on observations; when
27
+ absent the signal is silently skipped (backward-compatible).
28
+
29
+ - **`DriftThresholds.repetition_rate_threshold`** (P1-4): New field on
30
+ `DriftThresholds`, present on all three presets. `THRESHOLDS_GOAL`
31
+ preset added (`min_window_fill=4`, `repetition_rate_threshold=0.2`,
32
+ tighter across the board) and exposed via `SENSITIVITY_PRESETS["goal"]`.
33
+
34
+ - **`FallbackChain.goal_mode: bool = False`** (`config/schemas.py`, P1-5):
35
+ Profile-level flag. When `True`, the drift detector ignores
36
+ `drift_detection_sensitivity` and uses `THRESHOLDS_GOAL` instead
37
+ (stricter thresholds + `min_window_fill=4`). Designed for `/goal`
38
+ agent sessions where forward-progress stalls are more actionable.
39
+
40
+ - **`coderouter/state/suggest_rules.py`** (P1-6): Statistical rule
41
+ suggestion engine. `suggest_rules(WindowSummary) → list[RuleSuggestion]`
42
+ analyses the request journal and emits copy-paste YAML snippets.
43
+ Five rules: `provider_reorder` (cost rank), `enable_prompt_cache`
44
+ (high-token / low-hit providers), `enable_drift_detection` (reminder),
45
+ `low_sensitivity_small_window` (sparse-traffic guard), `goal_profile`
46
+ (output-divergence → `goal_mode: true`). Pure statistics — no LLM.
47
+
48
+ - **`coderouter replay --suggest-rules`** (`cli.py`, P1-6): New flag on
49
+ the existing `replay` subcommand. Reads the full request journal,
50
+ runs `suggest_rules`, and prints a formatted terminal report with
51
+ confidence badges and YAML snippets.
52
+
53
+ ### Changed
54
+
55
+ - **`ResponseObservation.response_fingerprint: str | None = None`**
56
+ (`drift_detection.py`): New optional field (slots-safe, defaults to
57
+ `None`). Fully backward-compatible — existing callers that don't
58
+ populate it get the same five-signal behaviour as before.
59
+
60
+ - **`FallbackEngine._observe_drift_signal`** (`fallback.py`): Accepts
61
+ new `response_fingerprint` kwarg. Non-streaming and streaming success
62
+ paths now compute and pass a fingerprint for the `goal_progress_stall`
63
+ signal. `goal_mode` check applies `THRESHOLDS_GOAL` when the profile
64
+ flag is set.
65
+
66
+ ### Files touched
67
+
68
+ ```
69
+ A coderouter/guards/_fingerprint.py
70
+ M coderouter/guards/__init__.py — module registry comment
71
+ M coderouter/guards/drift_detection.py — Signal 6, THRESHOLDS_GOAL, new fields
72
+ M coderouter/config/schemas.py — FallbackChain.goal_mode
73
+ M coderouter/routing/fallback.py — fingerprint wiring, goal_mode dispatch
74
+ A coderouter/state/suggest_rules.py
75
+ M coderouter/state/__init__.py — module registry comment
76
+ M coderouter/cli.py — replay --suggest-rules
77
+ A docs/articles/v1-saga/note-14-v0-4-goal-mode.md
78
+ M docs/articles/v1-saga/INDEX.md
79
+ M docs/inside/future.md
80
+ M CHANGELOG.md, pyproject.toml — 2.3.0a4 → 2.4.0
81
+ ```
82
+
83
+ ---
84
+
85
+ ## [v2.3.0a4] — 2026-05-08 (Plugin SDK — ruff cleanup)
86
+
87
+ Patch over `v2.3.0a3`. CI's `ruff check .` job surfaced six lint
88
+ findings in the new Plugin SDK code. None affect runtime behavior.
89
+
90
+ ### Fixed
91
+
92
+ - **RUF022**: `__all__` in `coderouter/plugins/__init__.py` is now
93
+ isort-sorted alphabetically.
94
+ - **RUF006**: `_fanout_observers` was using `asyncio.create_task`
95
+ without holding a strong reference. Asyncio's task tracker only
96
+ keeps a weakref, so a fanout-in-flight task could be GC'd before
97
+ the observer ran. Fixed by storing tasks in a per-engine
98
+ ``_observer_tasks: set[asyncio.Task[None]]`` and removing each
99
+ via ``task.add_done_callback(set.discard)`` on completion. The
100
+ attribute is lazy-initialized in `_fanout_observers` itself so
101
+ engines built via ``__new__`` (which bypass ``__init__``) still
102
+ work.
103
+ - **I001 + F841**: `tests/test_plugins_integration.py` had unused
104
+ imports (`AnthropicResponse`, `AnthropicUsage`) and an unused
105
+ local (`captured_chat`) left over from a build-engine helper
106
+ whose code path never ran. Removed the helper entirely; the
107
+ remaining tests exercise the engine's hook surface
108
+ (``_apply_input_filters`` / ``_fanout_observers`` /
109
+ ``_safe_observe``) directly, which is what they always actually
110
+ did.
111
+ - **I001**: `tests/test_plugins_loader.py` import block reordered
112
+ alphabetically by module name.
113
+
114
+ ### Files touched
115
+
116
+ ```
117
+ M coderouter/plugins/__init__.py — __all__ alphabetical
118
+ M coderouter/routing/fallback.py — task strong-ref set
119
+ M tests/test_plugins_integration.py — drop dead helper
120
+ M tests/test_plugins_loader.py — import order
121
+ M tests/test_plugins_registry.py — formatting nit (blank line)
122
+ M CHANGELOG.md, pyproject.toml — 2.3.0a3 → 2.3.0a4
123
+ ```
124
+
125
+ After this patch, ``ruff check .`` passes against every tracked
126
+ Python file in the repo.
127
+
128
+ ---
129
+
9
130
  ## [v2.3.0a3] — 2026-05-08 (Plugin SDK — LogRecord.module collision fix)
10
131
 
11
132
  Patch over `v2.3.0a2`. The wheel-install-and-test job in CI surfaced
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.3.0a3
3
+ Version: 2.4.0
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -47,7 +47,7 @@ Description-Content-Type: text/markdown
47
47
 
48
48
  <p align="center">
49
49
  <a href="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml"><img src="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
50
- <a href=""><img src="https://img.shields.io/badge/version-2.2.0-blue" alt="version"></a>
50
+ <a href="https://pypi.org/project/coderouter-cli/"><img src="https://img.shields.io/pypi/v/coderouter-cli?include_prereleases&color=blue&label=pypi" alt="pypi"></a>
51
51
  <a href=""><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="python"></a>
52
52
  <a href=""><img src="https://img.shields.io/badge/deps-5-brightgreen" alt="deps"></a>
53
53
  <a href=""><img src="https://img.shields.io/badge/license-MIT-yellow" alt="license"></a>
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml"><img src="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
9
- <a href=""><img src="https://img.shields.io/badge/version-2.2.0-blue" alt="version"></a>
9
+ <a href="https://pypi.org/project/coderouter-cli/"><img src="https://img.shields.io/pypi/v/coderouter-cli?include_prereleases&color=blue&label=pypi" alt="pypi"></a>
10
10
  <a href=""><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="python"></a>
11
11
  <a href=""><img src="https://img.shields.io/badge/deps-5-brightgreen" alt="deps"></a>
12
12
  <a href=""><img src="https://img.shields.io/badge/license-MIT-yellow" alt="license"></a>
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml"><img src="https://github.com/zephel01/CodeRouter/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
9
- <a href=""><img src="https://img.shields.io/badge/version-2.2.0-blue" alt="version"></a>
9
+ <a href="https://pypi.org/project/coderouter-cli/"><img src="https://img.shields.io/pypi/v/coderouter-cli?include_prereleases&color=blue&label=pypi" alt="pypi"></a>
10
10
  <a href=""><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="python"></a>
11
11
  <a href=""><img src="https://img.shields.io/badge/deps-5-brightgreen" alt="deps"></a>
12
12
  <a href=""><img src="https://img.shields.io/badge/license-MIT-yellow" alt="license"></a>
@@ -293,6 +293,18 @@ def _build_parser() -> argparse.ArgumentParser:
293
293
  metavar="N",
294
294
  help="Use only the last N entries (applied after --since and --provider filters).",
295
295
  )
296
+ # P1-6: --suggest-rules — statistical analysis → routing rule proposals.
297
+ replay.add_argument(
298
+ "--suggest-rules",
299
+ action="store_true",
300
+ help=(
301
+ "P1-6: analyse the request journal and print actionable routing "
302
+ "rule suggestions as copy-paste YAML snippets. Suggestions cover "
303
+ "provider reordering by cost, prompt_cache enablement, drift "
304
+ "detection configuration, and goal profile creation. "
305
+ "Can be combined with --since / --limit to scope the analysis window."
306
+ ),
307
+ )
296
308
 
297
309
  return parser
298
310
 
@@ -684,6 +696,25 @@ def _run_replay(args: argparse.Namespace) -> int:
684
696
  print("replay: no matching entries found.")
685
697
  return 0
686
698
 
699
+ if getattr(args, "suggest_rules", False):
700
+ # P1-6: statistical rule suggestion mode.
701
+ # Always compute a full window summary (ignores --compare / --provider).
702
+ from coderouter.state.suggest_rules import format_suggestions, suggest_rules
703
+ from coderouter.state.replay import summarize_window as _sw
704
+
705
+ # Re-read without provider filter so we see all providers.
706
+ all_entries = read_request_log(log_path, since=args.since)
707
+ if args.limit is not None and args.limit > 0:
708
+ all_entries = all_entries[-args.limit:]
709
+ full_summary = _sw(all_entries)
710
+ suggestions = suggest_rules(full_summary)
711
+ print(f"Request journal: {len(all_entries)} entries analysed")
712
+ print(f" Window: {full_summary.first_ts} → {full_summary.last_ts}")
713
+ print(f" Providers: {', '.join(sorted(full_summary.providers))}")
714
+ print()
715
+ print(format_suggestions(suggestions))
716
+ return 0
717
+
687
718
  if args.compare:
688
719
  provider_a, provider_b = args.compare
689
720
  comparison = compare_providers(entries, provider_a, provider_b)
@@ -658,6 +658,28 @@ class FallbackChain(BaseModel):
658
658
  ),
659
659
  )
660
660
 
661
+ # --- P1-5: goal_mode — tighter drift thresholds for /goal sessions -------
662
+ #
663
+ # When True, the drift detector automatically switches to the
664
+ # ``THRESHOLDS_GOAL`` preset regardless of ``drift_detection_sensitivity``,
665
+ # and lowers ``min_window_fill`` to 4 so stall detection fires faster.
666
+ #
667
+ # Intended for profiles routed by the ``/goal`` meta-command where
668
+ # the agent is expected to make steady forward progress. Repetition and
669
+ # length collapse are much more meaningful signals in that context than
670
+ # in a general-purpose chat session.
671
+ goal_mode: bool = Field(
672
+ default=False,
673
+ description=(
674
+ "P1-5: when True, automatically applies the ``goal`` drift "
675
+ "threshold preset (stricter thresholds, lower ``min_window_fill`` "
676
+ "of 4) for this profile. Overrides ``drift_detection_sensitivity`` "
677
+ "when drift_detection_action is not ``off``. Designed for "
678
+ "agent/goal sessions where forward-progress stalls are more "
679
+ "actionable than in ad-hoc chat."
680
+ ),
681
+ )
682
+
661
683
  # --- v2.0-H (L6): Mid-stream partial stitching --------------------------
662
684
  # * ``off`` — discard partial content on mid-stream failure (legacy).
663
685
  # * ``surface`` — return partial content as a truncated-but-valid response.
@@ -12,6 +12,8 @@ to hit:
12
12
  * :mod:`coderouter.guards.self_healing` — v2.0-J auto-exclude +
13
13
  restart + recovery probe
14
14
  * :mod:`coderouter.guards.continuous_probe` — v2.0-I background probing
15
+ * :mod:`coderouter.guards._fingerprint` — P1-4 response fingerprinting
16
+ for goal_progress_stall signal
15
17
 
16
18
  Each guard is a pure-functional / single-class module that the engine
17
19
  consults at the appropriate dispatch point. Guards never block the
@@ -0,0 +1,125 @@
1
+ """Response fingerprinting for goal_progress_stall detection (P1-4).
2
+
3
+ A "fingerprint" is a compact, order-independent signature of the *content*
4
+ of an assistant response — independent of surface variation (filler phrases,
5
+ minor rewordings). Two responses with the same fingerprint are considered
6
+ semantically repetitive for stall-detection purposes.
7
+
8
+ Algorithm
9
+ ---------
10
+ 1. Normalise: lowercase, strip punctuation, collapse whitespace.
11
+ 2. Extract the N most-frequent content words (excluding a small stop-list).
12
+ 3. Sort alphabetically, join with '|', SHA-256 → 12-hex prefix.
13
+
14
+ The 12-hex prefix gives 281 trillion distinct values — collision probability
15
+ across any 20-response window is negligible (< 1 in 10^15).
16
+
17
+ Why top-N content words instead of full hash?
18
+ ----------------------------------------------
19
+ A verbatim hash would fail to catch "I cannot do X. Let me try Y" vs
20
+ "Let me try Y as I cannot do X" — same stall, different hash. By
21
+ extracting the dominant vocabulary we get useful fuzzy equality without
22
+ the overhead of embedding models.
23
+
24
+ Usage
25
+ -----
26
+ from coderouter.guards._fingerprint import fingerprint_response
27
+
28
+ fp = fingerprint_response(response_text)
29
+ obs = ResponseObservation(..., response_fingerprint=fp)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import re
36
+ import unicodedata
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Stop-word list (English + common LLM filler)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ _STOP_WORDS: frozenset[str] = frozenset(
43
+ {
44
+ # English function words
45
+ "a", "an", "the", "and", "or", "but", "if", "in", "on", "at", "to",
46
+ "for", "of", "with", "by", "from", "as", "is", "it", "its", "be",
47
+ "was", "are", "were", "been", "has", "have", "had", "do", "does",
48
+ "did", "will", "would", "could", "should", "may", "might", "shall",
49
+ "this", "that", "these", "those", "i", "you", "he", "she", "we",
50
+ "they", "me", "him", "her", "us", "them", "my", "your", "his",
51
+ "their", "our", "what", "which", "who", "how", "when", "where",
52
+ "why", "not", "no", "so", "up", "out", "into", "about", "than",
53
+ "then", "there", "here", "also", "just", "can", "get", "all",
54
+ # Common LLM assistant filler
55
+ "certainly", "sure", "absolutely", "great", "happy", "help",
56
+ "please", "let", "know", "feel", "free", "answer", "question",
57
+ "response", "following", "based", "provide", "using",
58
+ }
59
+ )
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Number of top content words to include in the fingerprint
63
+ # ---------------------------------------------------------------------------
64
+
65
+ _TOP_N: int = 12
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Public API
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def fingerprint_response(text: str, *, top_n: int = _TOP_N) -> str:
74
+ """Return a 12-hex fingerprint string for *text*.
75
+
76
+ Parameters
77
+ ----------
78
+ text:
79
+ Raw assistant response text (plain text, not JSON).
80
+ top_n:
81
+ Number of most-frequent content words to include in the signature.
82
+ Defaults to ``_TOP_N`` (12). Lower values are more fuzzy; higher
83
+ values are more precise.
84
+
85
+ Returns
86
+ -------
87
+ A 12-character lowercase hexadecimal string, e.g. ``"a3f7b2c091de"``.
88
+ Returns ``""`` for empty / whitespace-only input.
89
+ """
90
+ if not text or not text.strip():
91
+ return ""
92
+
93
+ # 1. Unicode normalisation + lowercase
94
+ normalised = unicodedata.normalize("NFKC", text).lower()
95
+
96
+ # 2. Strip punctuation / digits, collapse whitespace
97
+ normalised = re.sub(r"[^\w\s]", " ", normalised)
98
+ normalised = re.sub(r"\d+", " ", normalised)
99
+ normalised = re.sub(r"\s+", " ", normalised).strip()
100
+
101
+ # 3. Tokenise and filter stop words (also skip very short tokens)
102
+ tokens = [w for w in normalised.split() if len(w) > 2 and w not in _STOP_WORDS]
103
+
104
+ if not tokens:
105
+ return ""
106
+
107
+ # 4. Count frequencies, take top-N
108
+ freq: dict[str, int] = {}
109
+ for tok in tokens:
110
+ freq[tok] = freq.get(tok, 0) + 1
111
+
112
+ # Require at least 3 distinct content words; single-word or near-empty
113
+ # responses (e.g. "xxxxx..." test stubs, error codes, bare ACKs) produce
114
+ # the same fingerprint every time and would falsely inflate the repetition
115
+ # rate. Returning "" marks these as "not fingerprinted" so detect_drift
116
+ # skips them entirely.
117
+ if len(freq) < 3:
118
+ return ""
119
+
120
+ top_words = sorted(freq, key=lambda w: (-freq[w], w))[:top_n]
121
+
122
+ # 5. Sort alphabetically → stable join → hash
123
+ signature = "|".join(sorted(top_words))
124
+ digest = hashlib.sha256(signature.encode()).hexdigest()
125
+ return digest[:12]
@@ -34,6 +34,10 @@ Signals
34
34
  * ``stop_anomaly_rate`` — fraction of responses with unexpected stop_reason
35
35
  (not "end_turn" / "tool_use" / "max_tokens")
36
36
  * ``error_rate`` — fraction of attempts that ended in failure
37
+ * ``goal_progress_stall`` (P1-4) — fraction of fingerprinted responses
38
+ whose fingerprint matches a previously-seen fingerprint in the window,
39
+ indicating the model is repeating itself without making progress.
40
+ Only fires when ``response_fingerprint`` is populated on observations.
37
41
 
38
42
  Thresholds are bundled as :class:`DriftThresholds` with three presets
39
43
  (``low`` / ``normal`` / ``high`` sensitivity).
@@ -71,6 +75,15 @@ class ResponseObservation:
71
75
  is_error: bool = False
72
76
  """True if the attempt ended in provider-failed / provider-failed-midstream."""
73
77
  stream: bool = False
78
+ response_fingerprint: str | None = None
79
+ """P1-4: compact content fingerprint of the response text.
80
+
81
+ When set, used by the ``goal_progress_stall`` signal to detect
82
+ repetition: the same fingerprint appearing multiple times in the
83
+ window indicates the model is not making progress. Computed by
84
+ :func:`coderouter.guards._fingerprint.fingerprint_response`.
85
+ Pass ``None`` (default) to opt-out — the signal is silently skipped.
86
+ """
74
87
 
75
88
 
76
89
  # ---------------------------------------------------------------------------
@@ -100,6 +113,12 @@ class DriftThresholds:
100
113
  length_collapse_ratio: float = 0.5
101
114
  """If recent half median is < 50% of earlier half median → collapse."""
102
115
 
116
+ # P1-4: repetition/stall threshold
117
+ repetition_rate_threshold: float = 0.4
118
+ """P1-4: fraction of fingerprinted responses whose fingerprint has
119
+ appeared before in the window. Above this rate → goal_progress_stall
120
+ signal fires (mild). Default 0.4 = 2 out of 5 responses are repeats."""
121
+
103
122
  # Minimum observations before detection fires
104
123
  min_window_fill: int = 6
105
124
  """Don't trigger until at least this many observations in the window."""
@@ -112,6 +131,7 @@ THRESHOLDS_LOW = DriftThresholds(
112
131
  tool_silence_rate=0.8,
113
132
  stop_anomaly_rate=0.6,
114
133
  error_rate=0.4,
134
+ repetition_rate_threshold=0.6,
115
135
  min_window_fill=10,
116
136
  )
117
137
 
@@ -123,6 +143,19 @@ THRESHOLDS_HIGH = DriftThresholds(
123
143
  tool_silence_rate=0.5,
124
144
  stop_anomaly_rate=0.3,
125
145
  error_rate=0.15,
146
+ repetition_rate_threshold=0.25,
147
+ min_window_fill=4,
148
+ )
149
+
150
+ # P1-5: goal-mode preset — tighter thresholds + lower min_window_fill.
151
+ # Applied automatically when the profile has goal_mode=True.
152
+ THRESHOLDS_GOAL = DriftThresholds(
153
+ empty_response_rate=0.2,
154
+ length_collapse_ratio=0.6,
155
+ tool_silence_rate=0.5,
156
+ stop_anomaly_rate=0.3,
157
+ error_rate=0.15,
158
+ repetition_rate_threshold=0.2,
126
159
  min_window_fill=4,
127
160
  )
128
161
 
@@ -130,6 +163,7 @@ SENSITIVITY_PRESETS: dict[str, DriftThresholds] = {
130
163
  "low": THRESHOLDS_LOW,
131
164
  "normal": THRESHOLDS_NORMAL,
132
165
  "high": THRESHOLDS_HIGH,
166
+ "goal": THRESHOLDS_GOAL,
133
167
  }
134
168
 
135
169
 
@@ -244,6 +278,27 @@ def detect_drift(
244
278
  if error_rate > thresholds.error_rate:
245
279
  mild_flags.append(f"error_rate={error_rate:.2f}")
246
280
 
281
+ # --- Signal 6: Goal progress stall (P1-4) ---
282
+ # Only active when at least some observations have a fingerprint.
283
+ # Computes: how many fingerprinted responses repeat a fingerprint
284
+ # already seen earlier in the window. High repetition → stall.
285
+ fingerprinted = [
286
+ obs for obs in window if obs.response_fingerprint # excludes None and ""
287
+ ]
288
+ if len(fingerprinted) >= 3:
289
+ seen: set[str] = set()
290
+ repeat_count = 0
291
+ for obs in fingerprinted:
292
+ fp = obs.response_fingerprint # guaranteed non-empty by filter above
293
+ if fp in seen:
294
+ repeat_count += 1
295
+ else:
296
+ seen.add(fp)
297
+ repetition_rate = repeat_count / len(fingerprinted)
298
+ signals["goal_progress_stall"] = round(repetition_rate, 3)
299
+ if repetition_rate > thresholds.repetition_rate_threshold:
300
+ mild_flags.append(f"goal_progress_stall={repetition_rate:.2f}")
301
+
247
302
  # --- Severity synthesis ---
248
303
  if severe_flags:
249
304
  severity: Literal["none", "mild", "severe"] = "severe"
@@ -43,17 +43,14 @@ from coderouter.plugins.loader import (
43
43
  from coderouter.plugins.registry import PluginRegistry
44
44
 
45
45
  __all__ = [
46
- # Active hooks
47
- "InputFilter",
48
- "Observer",
49
- # Future hooks (Protocol-only, no engine integration yet)
46
+ "PLUGIN_GROUPS_FUTURE",
47
+ "PLUGIN_GROUPS_V2_3",
48
+ "Adapter",
50
49
  "Frontend",
51
50
  "Guard",
51
+ "InputFilter",
52
+ "Observer",
52
53
  "OutputFilter",
53
- "Adapter",
54
- # Discovery + container
55
54
  "PluginRegistry",
56
55
  "discover_and_load",
57
- "PLUGIN_GROUPS_V2_3",
58
- "PLUGIN_GROUPS_FUTURE",
59
56
  ]
@@ -838,6 +838,12 @@ class FallbackEngine:
838
838
  # so tests that build the engine via ``FallbackEngine.__new__``
839
839
  # see an empty registry instead of AttributeError.
840
840
  self._plugin_registry: PluginRegistry = plugins or PluginRegistry.empty()
841
+ # v2.3.0: holds strong refs to in-flight Observer fanout tasks
842
+ # so the asyncio event loop's weak-ref bookkeeping doesn't GC
843
+ # them mid-flight (RUF006). Tasks remove themselves on done
844
+ # via ``add_done_callback(_observer_tasks.discard)`` in
845
+ # :meth:`_fanout_observers`.
846
+ self._observer_tasks: set[asyncio.Task[None]] = set()
841
847
  # Cache adapters so we don't re-instantiate per request
842
848
  self._adapters: dict[str, BaseAdapter] = {
843
849
  p.name: build_adapter(p) for p in config.providers
@@ -1277,6 +1283,7 @@ class FallbackEngine:
1277
1283
  stop_reason: str | None = None,
1278
1284
  is_error: bool = False,
1279
1285
  stream: bool = False,
1286
+ response_fingerprint: str | None = None,
1280
1287
  ) -> DriftVerdict | None:
1281
1288
  """v2.0-G (L4): record an observation and check for drift.
1282
1289
 
@@ -1288,9 +1295,18 @@ class FallbackEngine:
1288
1295
  - Emits ``drift-detected`` log.
1289
1296
  - If action is ``promote`` or ``reload``, demotes the provider
1290
1297
  via the adaptive rank machinery.
1298
+
1299
+ Parameters
1300
+ ----------
1301
+ response_fingerprint:
1302
+ P1-4: compact content fingerprint from
1303
+ :func:`coderouter.guards._fingerprint.fingerprint_response`.
1304
+ When set, enables the ``goal_progress_stall`` signal.
1305
+ Pass ``None`` (default) to skip that signal.
1291
1306
  """
1292
1307
  from coderouter.guards.drift_detection import (
1293
1308
  SENSITIVITY_PRESETS,
1309
+ THRESHOLDS_GOAL,
1294
1310
  ResponseObservation,
1295
1311
  detect_drift,
1296
1312
  )
@@ -1316,6 +1332,7 @@ class FallbackEngine:
1316
1332
  stop_reason=stop_reason,
1317
1333
  is_error=is_error,
1318
1334
  stream=stream,
1335
+ response_fingerprint=response_fingerprint,
1319
1336
  )
1320
1337
  self._drift_window.record(obs)
1321
1338
 
@@ -1338,10 +1355,15 @@ class FallbackEngine:
1338
1355
  return None
1339
1356
 
1340
1357
  # Run detection
1358
+ # P1-5: goal_mode overrides the sensitivity preset with the tighter
1359
+ # THRESHOLDS_GOAL regardless of drift_detection_sensitivity setting.
1341
1360
  window = self._drift_window.get_window(provider)
1342
- thresholds = SENSITIVITY_PRESETS.get(
1343
- chain_cfg.drift_detection_sensitivity, SENSITIVITY_PRESETS["normal"]
1344
- )
1361
+ if getattr(chain_cfg, "goal_mode", False):
1362
+ thresholds = THRESHOLDS_GOAL
1363
+ else:
1364
+ thresholds = SENSITIVITY_PRESETS.get(
1365
+ chain_cfg.drift_detection_sensitivity, SENSITIVITY_PRESETS["normal"]
1366
+ )
1345
1367
  verdict = detect_drift(window, thresholds)
1346
1368
 
1347
1369
  if not verdict.drifted:
@@ -1881,10 +1903,22 @@ class FallbackEngine:
1881
1903
  observers = self.plugins.observers
1882
1904
  if not observers:
1883
1905
  return
1906
+ # Lazy-init the task set for engines built via ``__new__`` —
1907
+ # mirrors the lazy ``plugins`` property pattern so legacy
1908
+ # tests that bypass __init__ don't crash here.
1909
+ if not hasattr(self, "_observer_tasks"):
1910
+ self._observer_tasks = set()
1884
1911
  for obs in observers:
1885
- asyncio.create_task(
1912
+ task = asyncio.create_task(
1886
1913
  self._safe_observe(obs, event_type, payload)
1887
1914
  )
1915
+ # Strong-ref keeps the task alive past the loop iteration;
1916
+ # ``discard`` cleans up after the task completes (success
1917
+ # or exception). Avoids the RUF006 footgun where
1918
+ # asyncio.create_task's weakref-only bookkeeping can let
1919
+ # the loop GC a fanout-in-progress task.
1920
+ self._observer_tasks.add(task)
1921
+ task.add_done_callback(self._observer_tasks.discard)
1888
1922
 
1889
1923
  async def _safe_observe(
1890
1924
  self,
@@ -2065,6 +2099,13 @@ class FallbackEngine:
2065
2099
  adapter.name, profile=request.profile
2066
2100
  )
2067
2101
  # v2.0-G (L4): drift detection observation (success path).
2102
+ # P1-4: compute response fingerprint for goal_progress_stall.
2103
+ _fp_text = " ".join(
2104
+ getattr(b, "text", "") or (b.get("text", "") if isinstance(b, dict) else "")
2105
+ for b in (resp.content or [])
2106
+ if (getattr(b, "type", None) or (b.get("type") if isinstance(b, dict) else None)) == "text"
2107
+ )
2108
+ from coderouter.guards._fingerprint import fingerprint_response as _fp
2068
2109
  self._observe_drift_signal(
2069
2110
  adapter.name,
2070
2111
  profile=request.profile,
@@ -2075,6 +2116,7 @@ class FallbackEngine:
2075
2116
  request_had_tools=bool(request.tools),
2076
2117
  stop_reason=resp.stop_reason,
2077
2118
  stream=False,
2119
+ response_fingerprint=_fp(_fp_text) if _fp_text else None,
2078
2120
  )
2079
2121
  # v1.9-A: pair every successful Anthropic response with a
2080
2122
  # cache-observed log line. Native Anthropic / LM Studio
@@ -2294,6 +2336,11 @@ class FallbackEngine:
2294
2336
  adapter.name, exc, partial_content=acc.partial_content
2295
2337
  ) from exc
2296
2338
  # v2.0-G (L4): drift detection observation (stream success).
2339
+ # P1-4: compute response fingerprint for goal_progress_stall.
2340
+ _stream_fp_text = " ".join(
2341
+ b.get("text", "") for b in acc.partial_content if b.get("type") == "text"
2342
+ )
2343
+ from coderouter.guards._fingerprint import fingerprint_response as _fp_s
2297
2344
  self._observe_drift_signal(
2298
2345
  adapter.name,
2299
2346
  profile=request.profile,
@@ -2302,6 +2349,7 @@ class FallbackEngine:
2302
2349
  request_had_tools=bool(request.tools),
2303
2350
  stop_reason=acc.stop_reason,
2304
2351
  stream=True,
2352
+ response_fingerprint=_fp_s(_stream_fp_text) if _stream_fp_text else None,
2305
2353
  )
2306
2354
  # v1.9-B2: pair the successful stream with a cache-observed
2307
2355
  # log line carrying the aggregated usage counters that the
@@ -0,0 +1,19 @@
1
+ """Persistent state layer (v2.0-K).
2
+
3
+ Five modules:
4
+
5
+ * :mod:`coderouter.state.store` — sqlite3 KV store for operational
6
+ metadata (budget totals, health
7
+ state, self-healing exclusions).
8
+ * :mod:`coderouter.state.audit_log` — JSONL structured event log with
9
+ rotation and CLI reader.
10
+ * :mod:`coderouter.state.request_log` — JSONL request metadata journal
11
+ (per-request token counts, cost,
12
+ provider — no request body).
13
+ * :mod:`coderouter.state.replay` — Statistical A/B analysis engine
14
+ over request journal entries.
15
+ * :mod:`coderouter.state.suggest_rules` — P1-6 rule suggestion engine:
16
+ analyses WindowSummary and emits
17
+ copy-paste YAML snippets for
18
+ routing optimisation.
19
+ """