coderouter-cli 2.13.0__tar.gz → 2.15.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.
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/CHANGELOG.md +254 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/PKG-INFO +2 -2
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/anthropic_native.py +110 -3
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/base.py +65 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/openai_compat.py +65 -2
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/cli.py +140 -2
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/loader.py +24 -1
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/schemas.py +209 -0
- coderouter_cli-2.15.0/coderouter/credentials.py +332 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/doctor_apply.py +54 -9
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/anthropic_routes.py +82 -12
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/dashboard_routes.py +24 -4
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/metrics_routes.py +13 -2
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/openai_routes.py +31 -2
- coderouter_cli-2.15.0/coderouter/ingress/ui_auth.py +94 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/logging.py +150 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/metrics/collector.py +40 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/metrics/prometheus.py +46 -0
- coderouter_cli-2.15.0/coderouter/rollback.py +217 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/__init__.py +8 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/fallback.py +304 -4
- coderouter_cli-2.15.0/coderouter/routing/fallback_trace.py +501 -0
- coderouter_cli-2.15.0/coderouter/secret_redaction.py +606 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/audit_log.py +10 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/request_log.py +6 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/vscode_init.py +166 -23
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/README.en.md +30 -8
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/README.md +35 -13
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher.en.md +2 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher.md +2 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/architecture.en.md +10 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/architecture.md +10 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/partial-stitch.en.md +6 -1
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/partial-stitch.md +5 -1
- coderouter_cli-2.15.0/docs/concepts/stream-truncation.en.md +252 -0
- coderouter_cli-2.15.0/docs/concepts/stream-truncation.md +247 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/external-agents-adapter.md +9 -1
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/orchestration-companion.md +3 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/v1.6-auto-router-verification.md +9 -0
- coderouter_cli-2.15.0/docs/guides/security.en.md +438 -0
- coderouter_cli-2.15.0/docs/guides/security.md +249 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/troubleshooting.en.md +34 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/troubleshooting.md +34 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/usage-guide.en.md +35 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/usage-guide.md +35 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/vscode.md +54 -9
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/openrouter-roster/README.md +4 -3
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/README.md +18 -2
- coderouter_cli-2.15.0/examples/providers.cli-session.yaml +127 -0
- coderouter_cli-2.15.0/examples/providers.llamacpp-vllm.yaml +544 -0
- coderouter_cli-2.15.0/examples/providers.opusplan.yaml +168 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/pyproject.toml +1 -1
- coderouter_cli-2.15.0/tests/test_credentials.py +348 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_doctor_apply.py +65 -0
- coderouter_cli-2.15.0/tests/test_fallback_reason.py +919 -0
- coderouter_cli-2.15.0/tests/test_fallback_tool_integrity.py +400 -0
- coderouter_cli-2.15.0/tests/test_rollback.py +195 -0
- coderouter_cli-2.15.0/tests/test_secret_redaction.py +340 -0
- coderouter_cli-2.15.0/tests/test_stream_truncation.py +1444 -0
- coderouter_cli-2.15.0/tests/test_ui_auth.py +142 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_vscode_init.py +145 -3
- coderouter_cli-2.13.0/docs/guides/security.en.md +0 -204
- coderouter_cli-2.13.0/docs/guides/security.md +0 -126
- coderouter_cli-2.13.0/examples/providers.llamacpp-vllm.yaml +0 -332
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/.gitignore +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/LICENSE +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/README.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/README.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/__main__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/adapters/registry.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/cli_stats.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/capability_registry.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/config/env_file.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/cost.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/data/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/data/model-capabilities.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/doctor.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/env_security.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/errors.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/gguf_introspect.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/_fingerprint.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/backend_health.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/context_budget.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/continuous_probe.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/drift_actions.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/drift_detection.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/memory_budget.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/memory_pressure.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/self_healing.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/guards/tool_loop.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/hardware.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/app.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/ingress/launcher_routes.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/language_tax.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/launcher_devices.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/launcher_speculative.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/launcher_swap.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/metrics/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/output_filters.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/base.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/loader.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/plugins/registry.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/py.typed +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/adaptive.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/auto_router.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/budget.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/routing/capability.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/replay.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/store.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/state/suggest_rules.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/token_estimation.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/token_estimation_accurate.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/anthropic.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/convert.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/coderouter/translation/tool_repair.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/assets/dashboard-demo.png +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/claude-code-llamacpp-vllm.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/claude-code-llamacpp-vllm.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/external-agents.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/external-agents.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/gguf_dl.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/gguf_dl.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/hf-ollama-models.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/hf-ollama-models.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/install-backends.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/install-backends.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher-quickstart.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/launcher-quickstart.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/llamacpp-direct.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/llamacpp-direct.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/lmstudio-direct.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/lmstudio-direct.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/backends/verify-ollama-0.23.1.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/context-budget.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/context-budget.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/continuous-probing.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/continuous-probing.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/drift-detection.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/concepts/drift-detection.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/agent-cli-plugin-extraction.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/launcher-model-swap.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/launcher-multi-build.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/v1.5-dashboard-mockup.html +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/designs/v1.6-auto-router.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/free-tier-guide.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/free-tier-guide.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/language-tax.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/language-tax.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/remote-access.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/remote-access.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/subagent-routing.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/guides/subagent-routing.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/low-memory-integration.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/low-memory-integration.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/openrouter-roster/CHANGES.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/openrouter-roster/latest.json +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.4.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.5-verify.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.5.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.6.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v0.7.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v1.0-verify.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/retrospectives/v1.0.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/quickstart.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/quickstart.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/when-do-i-need-coderouter.en.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/docs/start/when-do-i-need-coderouter.md +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/.env.example +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers-agent-cli.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers-multiagent.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.context-budget-test.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.nvidia-nim.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.ollama-auto-custom.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.ollama-auto.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.ollama-free-chain.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.raspberrypi.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.swap.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/examples/providers.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/demo_traffic.sh +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/openrouter_roster_diff.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/smoke_v2_2.sh +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify-providers.yaml +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify_ollama_0_23.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify_v0_5.sh +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/scripts/verify_v1_0.sh +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/__init__.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/conftest.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_adapter_anthropic.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_agent_cli.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_audit_log.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_auto_router.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_auto_router_cjk.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_backend_health.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_budget.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_degraded_payload.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_registry.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_registry_cache_control.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_capability_tool_choice.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_claude_code_suitability.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_cli.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_cli_bind_warning.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_cli_stats.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_config.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_config_cwd_warning.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_config_shim_actions.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_context_budget.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_continuous_probe.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_dashboard_endpoint.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_doctor.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_doctor_cache_probe.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_drift_actions.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_drift_detection.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_drift_detection_integration.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_env_file.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_env_security.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_errors.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_examples_yaml.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_examples_yaml_roundtrip.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_anthropic.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_cache_control.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_cache_observed.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_cache_strip.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_empty_response.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_misconfig_warn.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_paid_gate.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_thinking.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fallback_tool_choice_emulate.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h1_prometheus_drift.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h2_h6_translation.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h3_client_reuse.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h4_h5_guards.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h7_probe_restore.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_h8_launcher_auth.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mA_routing.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mB_adapters.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mC_convert.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mD_logging_metrics.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mE_config_validation.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_mF_ingress.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_fix_v213_launcher_hardening.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_gguf_introspect.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_guards_tool_loop.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_hardware.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_import_hygiene.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_ingress_anthropic.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_ingress_count_tokens.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_ingress_profile.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_language_tax.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_language_tax_integration.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_backend_variants.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_config_bench.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_devices.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_devices_detect.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_devices_routes.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_devices.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_events.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_readiness.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_gui_sweep_close.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_mtp.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_mtp_fallback.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_provider_sync.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_readiness_restart.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_swap.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_swap_review.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_config.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_gui.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_routes.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_launcher_variant_sweep.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_memory_budget.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_memory_pressure.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_cache.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_collector.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_cost.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_endpoint.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_jsonl.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_prometheus.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_metrics_prometheus_cache.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_models_passthrough.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_openai_compat.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_openrouter_roster_diff.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_output_filters.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_output_filters_adapters.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_packaging.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_partial_stitch.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugin_adapter.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugins_integration.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugins_loader.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_plugins_registry.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_reasoning_strip.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_repair_byte_fallback.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_request_log.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_role_normalization.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_routing_adaptive.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_self_healing.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_setup_sh.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_state_store.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_stop_reason_forward_compat.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_token_estimation.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_token_estimation_accurate.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tokens_saved_metric.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_differential.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_lenient.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_r4.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_tool_repair_scanners.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_toolrepair_bench.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_translation_anthropic.py +0 -0
- {coderouter_cli-2.13.0 → coderouter_cli-2.15.0}/tests/test_translation_reverse.py +0 -0
|
@@ -9,6 +9,260 @@ are kept verbatim where the Japanese text itself is the subject).
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [v2.15.0] — 2026-08-21 (fallback explainability + stream truncation detection)
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Fallback explainability — `X-CodeRouter-Fallback-*` response headers.**
|
|
17
|
+
When a request changes providers, the response now says who it left, who
|
|
18
|
+
took over, and why: `X-CodeRouter-Fallback-From` / `-To` / `-Reason` /
|
|
19
|
+
`-Chain` (multi-hop chains render as `local>ollama>openrouter` with one
|
|
20
|
+
comma-separated reason per departure). Reasons come from a single
|
|
21
|
+
canonical vocabulary in `coderouter/routing/fallback_trace.py` covering
|
|
22
|
+
both chain-resolve filters (`paid-gate`, `budget-exceeded`,
|
|
23
|
+
`memory-pressure`, `backend-unhealthy`, `self-healing-excluded`,
|
|
24
|
+
`unknown-provider`) and runtime attempt failures (`timeout`,
|
|
25
|
+
`rate-limit`, `auth`, `upstream-5xx`, `upstream-4xx`, `connection`,
|
|
26
|
+
`empty-response`, `empty-stream`). The same trail is emitted as
|
|
27
|
+
structured `fallback-occurred` log lines (auditable) and, on the
|
|
28
|
+
Anthropic streaming path, as a trailing `coderouter_fallback` SSE
|
|
29
|
+
metadata event for reasons discovered after the HTTP headers ship.
|
|
30
|
+
Purely additive — a request served by its first provider produces no new
|
|
31
|
+
headers, no SSE trailer and no log lines.
|
|
32
|
+
- **Stream truncation detection — `stream_truncation_action`
|
|
33
|
+
(`off` | `warn` | `error`, default `off`).** An upstream can end its HTTP
|
|
34
|
+
body *cleanly* while the LLM protocol carried inside it is still
|
|
35
|
+
mid-message: no `message_stop` on the Anthropic wire, no `data: [DONE]`
|
|
36
|
+
and no `finish_reason` on the OpenAI wire. llama.cpp slot preemption, an
|
|
37
|
+
`--n-predict` cut-off, a proxy closing an EOF-delimited body, or a local
|
|
38
|
+
server that OOM'd all look exactly like that. Transport breakage
|
|
39
|
+
(timeouts, `httpx.RemoteProtocolError`) was already caught; this is the
|
|
40
|
+
layer the transport cannot see, and until now CodeRouter reported it as a
|
|
41
|
+
*successful* completion — the translation layer's terminator-synthesis
|
|
42
|
+
guards fabricated `stop_reason: end_turn` so the client would not hang,
|
|
43
|
+
which also erased every trace of the cut. The synthesis stays (removing
|
|
44
|
+
it hangs Claude Code); what is new is that the engine gets told. Both
|
|
45
|
+
adapters now track whether a terminator arrived, and under `error` raise
|
|
46
|
+
a retryable `StreamTruncatedError` that joins the *existing* branches: no
|
|
47
|
+
bytes forwarded yet → fall back to the next provider with reason
|
|
48
|
+
`stream-truncated`; bytes already out → `MidStreamError`, which
|
|
49
|
+
`partial_stitch_action: surface` renders as a graceful close carrying a
|
|
50
|
+
`coderouter_partial` event labelled `stream_truncated`. If *every*
|
|
51
|
+
provider in the chain truncates before any byte reached the client, the
|
|
52
|
+
request ends in `NoProvidersAvailableError` (an `overloaded_error` SSE
|
|
53
|
+
frame) rather than flushing a half-written message — see **Fixed** below.
|
|
54
|
+
Because the failure is an ordinary `AdapterError`, the L2/L4/L5/L6
|
|
55
|
+
self-healing guards learn it — a backend that keeps going quiet gets
|
|
56
|
+
demoted by adaptive routing instead of being *preferred* for its fast
|
|
57
|
+
first byte. `warn` measures without changing a single byte of output;
|
|
58
|
+
`off` is byte-for-byte v2.14.0 **on the wire** — see the caveat on
|
|
59
|
+
guard-internal state below. Observability: a `stream-truncation-detected` log
|
|
60
|
+
line (with `wire`, `events_forwarded`, `saw_stream_start`,
|
|
61
|
+
`tool_call_in_flight`), `stream_truncated_total` in `/metrics.json` and
|
|
62
|
+
Prometheus (labelled by provider and by action), and the
|
|
63
|
+
`stream-truncated` reason on the `X-CodeRouter-Fallback-*` headers.
|
|
64
|
+
Terminator matching is deliberately lenient — a `message_delta` carrying
|
|
65
|
+
a `stop_reason`, or a `finish_reason` on any choice, both count — so
|
|
66
|
+
providers that legitimately omit the sentinel are not flagged. On the
|
|
67
|
+
Anthropic streaming path, pre-content fallback additionally needs
|
|
68
|
+
`empty_response_action: fallback`, which is the knob that withholds the
|
|
69
|
+
opening events from the client. Known gap: the terminator synthesis this
|
|
70
|
+
entry refers to lives in `translation/convert.py`, so the one route that
|
|
71
|
+
does not pass through it — `/v1/messages` served by a `kind: anthropic`
|
|
72
|
+
backend, i.e. native passthrough — has never had it. Under `off` a
|
|
73
|
+
truncated native stream still reaches the client unterminated, exactly as
|
|
74
|
+
in v2.14.0; that route is the strongest argument for moving to `warn`.
|
|
75
|
+
Docs: `docs/concepts/stream-truncation.md` / `.en.md`.
|
|
76
|
+
- **Tool-call integrity regression tests across a fallback hop**
|
|
77
|
+
(`tests/test_fallback_tool_integrity.py`): the conversation handed to the
|
|
78
|
+
second provider keeps every `tool_use` / `tool_result` id paired, and the
|
|
79
|
+
response the client receives is still a structurally valid `tool_use`
|
|
80
|
+
block on both the buffered and the streaming path.
|
|
81
|
+
- **Stream truncation tests** (`tests/test_stream_truncation.py`): both
|
|
82
|
+
adapters driven over real SSE bodies (off / warn / error, the
|
|
83
|
+
terminator-leniency false-positive guards, the tool-call-in-flight flag),
|
|
84
|
+
engine-level fallback and `MidStreamError` integration, the ingress
|
|
85
|
+
`coderouter_partial` labelling, and an explicit `off` regression proving
|
|
86
|
+
the default path — including the H6 terminator synthesis — is unchanged.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- **Anthropic streaming: a provider that failed before forwarding any byte
|
|
91
|
+
was invisible to the self-healing guards.** Under
|
|
92
|
+
`empty_response_action: fallback` the engine withholds the opening events
|
|
93
|
+
until real content appears, and an `AdapterError` raised in that window
|
|
94
|
+
is handled by swapping providers instead of raising. That branch recorded
|
|
95
|
+
an adaptive failure but never called the L2 memory-pressure / L4 drift /
|
|
96
|
+
L5 backend-health hooks — while `_observe_provider_success` had *already*
|
|
97
|
+
fired the moment the first event landed. The net effect was a backend
|
|
98
|
+
that opens a stream and then dies keeping a clean bill of health, and
|
|
99
|
+
adaptive routing preferring it for its fast first byte. The identical
|
|
100
|
+
failure under `empty_response_action: off` takes the mid-stream path and
|
|
101
|
+
*was* observed, so provider health effectively depended on an unrelated
|
|
102
|
+
knob. Both hooks now run on that branch (and the error joins the `errors`
|
|
103
|
+
list carried by `NoProvidersAvailableError`), matching the mid-stream
|
|
104
|
+
sibling. A stream that terminates cleanly with no content is still not a
|
|
105
|
+
failure — it is a 200 blank, and reaches neither hook.
|
|
106
|
+
**Operational note:** this is a behavior change for anyone already
|
|
107
|
+
running `empty_response_action: fallback` — pre-content failures now
|
|
108
|
+
reach L2/L4/L5, so if `memory_pressure_action: skip` and/or
|
|
109
|
+
`backend_health_action: demote` are also set, skips and demotions can
|
|
110
|
+
become *more frequent* for a backend that was silently dying before real
|
|
111
|
+
content shipped, even though nothing about those knobs' own
|
|
112
|
+
configuration changed. This is independent of `stream_truncation_action`
|
|
113
|
+
— it fires under the default `off` exactly as it does under `warn` /
|
|
114
|
+
`error`, since the branch it lives in is the *pre-existing*
|
|
115
|
+
`empty_response_action: fallback` path, not the new truncation
|
|
116
|
+
detection. See `docs/concepts/stream-truncation.md` / `.en.md` for
|
|
117
|
+
details.
|
|
118
|
+
- **Anthropic streaming: a chain where every provider truncated left the
|
|
119
|
+
client hanging.** On chain exhaustion the engine flushes the last
|
|
120
|
+
buffered preamble so a genuinely empty chain returns a well-formed 200
|
|
121
|
+
blank (`message_start` … `message_stop`). A *truncated* stream's buffer
|
|
122
|
+
has no `message_stop` by definition, so flushing it emitted an SSE
|
|
123
|
+
sequence that never ended — no exception, no error frame, and a client
|
|
124
|
+
waiting forever — and charged `empty_responses_total` for a stream that
|
|
125
|
+
was not empty. The flush is now gated on the buffer actually terminating;
|
|
126
|
+
a truncation-produced buffer falls through to `NoProvidersAvailableError`,
|
|
127
|
+
which the ingress turns into a terminal `overloaded_error` SSE frame. Only
|
|
128
|
+
reachable with `stream_truncation_action: error`, so the default path is
|
|
129
|
+
unchanged, and the empty-chain 200 blank is untouched (including the
|
|
130
|
+
mixed case where a truncated hop is followed by a provider that answered
|
|
131
|
+
with a well-formed empty message — that buffer still gets flushed).
|
|
132
|
+
- **`partial-stitch-surfaced` log shape under the default `off`.** The
|
|
133
|
+
`reason` key added for truncation labelling was attached
|
|
134
|
+
unconditionally, so every `partial_stitch_action: surface` log line
|
|
135
|
+
gained a field even where truncation detection is disabled and the value
|
|
136
|
+
could only ever be `mid_stream_failure`. It is now emitted only when it
|
|
137
|
+
says something new, restoring the v2.14.0 key set. (`/metrics.json` was
|
|
138
|
+
never affected — `MetricsCollector` whitelists the fields it copies.)
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## [v2.14.0] — 2026-08-09 (credential hygiene + undo)
|
|
143
|
+
|
|
144
|
+
Five changes that came out of reading `duolahypercho/codex-router` and
|
|
145
|
+
comparing it with what CodeRouter actually does. Four of them close holes
|
|
146
|
+
this project had; one imports a capability it was missing. Everything is
|
|
147
|
+
opt-in except the `.envrc` behaviour change called out under **Changed
|
|
148
|
+
(BREAKING)**.
|
|
149
|
+
|
|
150
|
+
### Added
|
|
151
|
+
|
|
152
|
+
- **`credential.source: cli_session` — borrow the token a vendor CLI
|
|
153
|
+
already wrote.** A subscription-authenticated provider (Kimi Code CLI,
|
|
154
|
+
Grok CLI, …) can now be an ordinary `openai_compat` / `anthropic`
|
|
155
|
+
entry: CodeRouter reads the OAuth token out of the JSON file the
|
|
156
|
+
vendor's CLI maintains and makes the HTTP call itself. Until now the
|
|
157
|
+
only answer to those was `kind: agent_cli`, which spawns the CLI once
|
|
158
|
+
per request and therefore has no streaming, no tool-call repair, no
|
|
159
|
+
context-budget guard, and does not sit in a fallback chain — the
|
|
160
|
+
subscription ended up an island. It is now a chain link:
|
|
161
|
+
`providers: [kimi-sub, free-cloud, local-llama]` just works.
|
|
162
|
+
Refresh delegates to the vendor's own CLI (`refresh.command`, an argv
|
|
163
|
+
list run with `shell=False`) rather than reimplementing OAuth, because
|
|
164
|
+
endpoints, client ids and rotation policies all change and a bespoke
|
|
165
|
+
implementation rots. Refresh is single-flighted in-process and
|
|
166
|
+
serialised across processes with an advisory `flock`. `credential` and
|
|
167
|
+
`api_key_env` are mutually exclusive at load time, `credential.path`
|
|
168
|
+
must live under `$HOME`, and a missing or malformed session file
|
|
169
|
+
resolves to `None` (unauthenticated request → upstream 401 → the chain
|
|
170
|
+
moves on) instead of raising. See `examples/providers.cli-session.yaml`.
|
|
171
|
+
- **`coderouter rollback`** — restores `providers.yaml`,
|
|
172
|
+
`~/.coderouter/model-capabilities.yaml` and (with `--workspace`)
|
|
173
|
+
`.vscode/settings.json` / `.envrc` from their `.bak` siblings.
|
|
174
|
+
`doctor --apply` and `vscode-init` have always written a backup and
|
|
175
|
+
never offered a way back. Restore is a **swap**: the current contents
|
|
176
|
+
become the new `.bak`, so a second run returns you to where you
|
|
177
|
+
started. Exit 0 restored / 2 nothing to restore / 1 a restore failed.
|
|
178
|
+
- **`coderouter doctor --check-secrets`** — proves the log-redaction
|
|
179
|
+
filter scrubs by pushing a canary through it, reports which declared
|
|
180
|
+
`api_key_env` vars are actually set, flags credentials pasted into a
|
|
181
|
+
`base_url`, and scans the already-written `requests.jsonl` /
|
|
182
|
+
`audit.jsonl` (plus rotated `.1`) for live key values. Same 0/2/1 exit
|
|
183
|
+
contract as `--check-env`. The log scan is the part that produces
|
|
184
|
+
evidence rather than reassurance.
|
|
185
|
+
- **`CODEROUTER_METRICS_TOKEN`** — opt-in token auth for `/dashboard`,
|
|
186
|
+
`/metrics.json` and `/metrics`, mirroring `CODEROUTER_LAUNCHER_TOKEN`.
|
|
187
|
+
|
|
188
|
+
### Changed (BREAKING)
|
|
189
|
+
|
|
190
|
+
- **`vscode-init --with-envrc --force` no longer replaces `.envrc`.** The
|
|
191
|
+
block CodeRouter owns is now fenced between
|
|
192
|
+
`# BEGIN coderouter-managed` / `# END coderouter-managed`, and only
|
|
193
|
+
that block is rewritten. Consequences:
|
|
194
|
+
- A re-run against a fenced file needs **no `--force`** — rewriting our
|
|
195
|
+
own fence is not destructive, so a port change is an ordinary update
|
|
196
|
+
instead of a conflict to override.
|
|
197
|
+
- `--force` now means "adopt a file I did not write": the block is
|
|
198
|
+
appended and every existing line survives. Previously it deleted
|
|
199
|
+
them, which is how the H-11 `.envrc` incident happened — including
|
|
200
|
+
the `source_env_if_exists .envrc.local` line the docs tell users to
|
|
201
|
+
add.
|
|
202
|
+
- A managed variable exported *outside* the fence is refused, because
|
|
203
|
+
direnv applies exports in order and a silent duplicate leaves nobody
|
|
204
|
+
able to say which value is live. Commented-out lines are not
|
|
205
|
+
conflicts.
|
|
206
|
+
- A file byte-identical to a pre-v2.14.0 generation is adopted into the
|
|
207
|
+
fence silently, so upgrading does not demand `--force` for our own
|
|
208
|
+
previous output.
|
|
209
|
+
`settings.json` handling is unchanged (it already refused to overwrite
|
|
210
|
+
a user-owned value without `--force`).
|
|
211
|
+
- **A failed `doctor --apply` is now a no-op instead of a partial one.**
|
|
212
|
+
The write loop had no error handling: an apply spanning `providers.yaml`
|
|
213
|
+
and `model-capabilities.yaml` left the first file rewritten when the
|
|
214
|
+
second failed. It now restores what it already wrote (and deletes what
|
|
215
|
+
it created) before raising.
|
|
216
|
+
|
|
217
|
+
### Fixed (security)
|
|
218
|
+
|
|
219
|
+
- **Credentials are scrubbed from every log sink.** CodeRouter had no
|
|
220
|
+
notion of "this string is a secret": keys were read by
|
|
221
|
+
`resolve_api_key`, dropped into `x-api-key` / `Authorization`, and
|
|
222
|
+
nothing masked them because nothing knew which strings needed masking.
|
|
223
|
+
No call site logged a header dict, so this was not a live leak — the
|
|
224
|
+
problem was that the safety of every future log line depended on
|
|
225
|
+
whoever wrote it remembering what was in their hand. There is now a
|
|
226
|
+
process-global registry keyed on the exact credential value, armed by
|
|
227
|
+
`resolve_api_key` (the choke point every key passes through, plugins
|
|
228
|
+
included) and pre-armed by `load_config`, plus a
|
|
229
|
+
`SecretRedactingFilter` on the stderr handler, `RequestLogHandler` and
|
|
230
|
+
`AuditLogHandler`. It scrubs the message, printf args, `exc_text` and
|
|
231
|
+
every `extra={...}` field, recursing into nested dicts and lists.
|
|
232
|
+
Exact-match registry first (zero false positives); a small set of
|
|
233
|
+
anchored backstop patterns (`sk-*`, `gh*_`, `AIza*`, `Bearer`,
|
|
234
|
+
`?api_key=`, URL userinfo) covers credentials that never passed through
|
|
235
|
+
the resolver. Values under 8 characters are refused — redacting those
|
|
236
|
+
would corrupt ordinary log text.
|
|
237
|
+
- **`/dashboard`, `/metrics.json` and `/metrics` were unauthenticated
|
|
238
|
+
with no way to close them.** They change nothing, so they were never
|
|
239
|
+
part of the H-8 work, but `/metrics.json` returns every provider's
|
|
240
|
+
name, kind, paid flag and `base_url` plus the profile graph — the full
|
|
241
|
+
topology of which models an operator runs and which vendors they pay.
|
|
242
|
+
With `CODEROUTER_METRICS_TOKEN` unset they stay open exactly as before
|
|
243
|
+
(no running scrape breaks on upgrade) and log a one-time
|
|
244
|
+
`metrics-auth-disabled` warning. The check runs **before** the payload
|
|
245
|
+
is assembled, so a 401 leaks nothing, and the page receives only a
|
|
246
|
+
boolean — never the token. `base_url` now also goes through the
|
|
247
|
+
scrubber on its way into the JSON, since it is the one config field an
|
|
248
|
+
operator can paste a credential into and this endpoint hands it to a
|
|
249
|
+
browser.
|
|
250
|
+
|
|
251
|
+
### Tests
|
|
252
|
+
|
|
253
|
+
- 2572 passed / 1 skipped (was 2464 on `main`), ruff clean, and 51 mypy
|
|
254
|
+
errors against 53 on `main` (net −2; strict mypy is not CI-enforced).
|
|
255
|
+
- New: `tests/test_secret_redaction.py` (36),
|
|
256
|
+
`tests/test_credentials.py` (25), `tests/test_rollback.py` (17),
|
|
257
|
+
`tests/test_ui_auth.py` (17), plus 9 in `tests/test_vscode_init.py` and
|
|
258
|
+
1 in `tests/test_doctor_apply.py`.
|
|
259
|
+
- `test_existing_envrc_force_overwrites` asserted the old destructive
|
|
260
|
+
`--force` contract and is rewritten as
|
|
261
|
+
`test_existing_envrc_force_adopts_without_destroying`, pinning the
|
|
262
|
+
opposite.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
12
266
|
## [v2.13.0] — 2026-08-07 (remaining-high security hardening)
|
|
13
267
|
|
|
14
268
|
### Changed (BREAKING)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.15.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
|
|
@@ -44,9 +44,14 @@ from coderouter.adapters.base import (
|
|
|
44
44
|
ChatResponse,
|
|
45
45
|
ProviderCallOverrides,
|
|
46
46
|
StreamChunk,
|
|
47
|
+
StreamTruncatedError,
|
|
48
|
+
)
|
|
49
|
+
from coderouter.credentials import resolve_provider_credential
|
|
50
|
+
from coderouter.logging import (
|
|
51
|
+
get_logger,
|
|
52
|
+
log_output_filter_applied,
|
|
53
|
+
log_stream_truncation_detected,
|
|
47
54
|
)
|
|
48
|
-
from coderouter.config.loader import resolve_api_key
|
|
49
|
-
from coderouter.logging import get_logger, log_output_filter_applied
|
|
50
55
|
from coderouter.output_filters import OutputFilterChain
|
|
51
56
|
from coderouter.translation.anthropic import (
|
|
52
57
|
AnthropicRequest,
|
|
@@ -74,6 +79,74 @@ _RETRYABLE_STATUSES = {404, 408, 425, 429, 500, 502, 503, 504}
|
|
|
74
79
|
_DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
75
80
|
|
|
76
81
|
|
|
82
|
+
class _AnthropicTerminatorTracker:
|
|
83
|
+
"""v2.15.0: watch an Anthropic SSE event stream for its terminator.
|
|
84
|
+
|
|
85
|
+
The parse loop in :meth:`AnthropicAdapter.stream_anthropic` used to be
|
|
86
|
+
stateless across events — ``current_event`` was reset on every blank
|
|
87
|
+
line and nothing remembered whether the stream had ended *properly*.
|
|
88
|
+
That made "upstream sent 30 deltas and hung up" indistinguishable from
|
|
89
|
+
"upstream sent the whole message". This tracker is the missing memory.
|
|
90
|
+
|
|
91
|
+
What counts as a terminator (deliberately generous, to keep the false
|
|
92
|
+
positive rate down — see ``stream_truncation_action`` in
|
|
93
|
+
``config/schemas.py``):
|
|
94
|
+
|
|
95
|
+
* ``message_stop`` — the canonical Anthropic terminator.
|
|
96
|
+
* ``message_delta`` carrying a non-null ``stop_reason`` — the message is
|
|
97
|
+
semantically complete even if the provider omits ``message_stop``.
|
|
98
|
+
Some Anthropic-compatible local servers do exactly that, and their
|
|
99
|
+
content is *not* truncated, so flagging them would be a false positive.
|
|
100
|
+
* a top-level ``error`` event — the upstream ended the stream on purpose
|
|
101
|
+
and the frame is forwarded to the client verbatim (current behavior).
|
|
102
|
+
Reporting truncation on top would double-report one failure.
|
|
103
|
+
|
|
104
|
+
``open_block_type`` tracks the currently-open content block so a stream
|
|
105
|
+
cut in the middle of a ``tool_use`` block's ``input_json_delta`` can be
|
|
106
|
+
reported as such: that is the case where the translation layer's
|
|
107
|
+
terminator synthesis closes a block whose argument JSON is provably
|
|
108
|
+
incomplete.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
__slots__ = ("events", "open_block_type", "saw_start", "saw_terminator")
|
|
112
|
+
|
|
113
|
+
def __init__(self) -> None:
|
|
114
|
+
"""Start with nothing observed."""
|
|
115
|
+
self.saw_terminator: bool = False
|
|
116
|
+
self.saw_start: bool = False
|
|
117
|
+
self.events: int = 0
|
|
118
|
+
self.open_block_type: str | None = None
|
|
119
|
+
|
|
120
|
+
def observe(self, event_type: str, data: Any) -> None:
|
|
121
|
+
"""Fold one parsed SSE event into the tracked state.
|
|
122
|
+
|
|
123
|
+
``data`` is whatever ``json.loads`` produced for the block, so it is
|
|
124
|
+
not guaranteed to be a mapping; non-dict payloads are treated as
|
|
125
|
+
empty rather than raising inside a streaming hot path.
|
|
126
|
+
"""
|
|
127
|
+
self.events += 1
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
data = {}
|
|
130
|
+
if event_type == "message_start":
|
|
131
|
+
self.saw_start = True
|
|
132
|
+
elif event_type == "content_block_start":
|
|
133
|
+
block = data.get("content_block")
|
|
134
|
+
self.open_block_type = block.get("type") if isinstance(block, dict) else None
|
|
135
|
+
elif event_type == "content_block_stop":
|
|
136
|
+
self.open_block_type = None
|
|
137
|
+
elif event_type == "message_delta":
|
|
138
|
+
delta = data.get("delta")
|
|
139
|
+
if isinstance(delta, dict) and delta.get("stop_reason"):
|
|
140
|
+
self.saw_terminator = True
|
|
141
|
+
elif event_type in {"message_stop", "error"}:
|
|
142
|
+
self.saw_terminator = True
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def tool_call_in_flight(self) -> bool:
|
|
146
|
+
"""True when a ``tool_use`` block was still open at end-of-stream."""
|
|
147
|
+
return self.open_block_type == "tool_use"
|
|
148
|
+
|
|
149
|
+
|
|
77
150
|
def anthropic_messages_url(base_url: str) -> str:
|
|
78
151
|
"""Resolve the ``/v1/messages`` endpoint URL from a ``base_url``.
|
|
79
152
|
|
|
@@ -134,7 +207,9 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
134
207
|
self.config.extra_body.get("anthropic_version", _DEFAULT_ANTHROPIC_VERSION)
|
|
135
208
|
),
|
|
136
209
|
}
|
|
137
|
-
|
|
210
|
+
# v2.14.0: see openai_compat._headers — same resolver, so an
|
|
211
|
+
# Anthropic-protocol provider can also ride a CLI session token.
|
|
212
|
+
api_key = resolve_provider_credential(self.config)
|
|
138
213
|
if api_key:
|
|
139
214
|
headers["x-api-key"] = api_key
|
|
140
215
|
# v0.4-D: forward the client's anthropic-beta header verbatim.
|
|
@@ -473,6 +548,12 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
473
548
|
filter_chains: dict[int, OutputFilterChain] = {}
|
|
474
549
|
logged_flag: list[bool] = [False]
|
|
475
550
|
|
|
551
|
+
# v2.15.0 (stream-truncation): remember whether the upstream ever
|
|
552
|
+
# terminated its message. Resolved once per call — the knob is a
|
|
553
|
+
# profile-level setting threaded in through ProviderCallOverrides.
|
|
554
|
+
truncation_action = self.effective_stream_truncation_action(overrides)
|
|
555
|
+
terminator = _AnthropicTerminatorTracker()
|
|
556
|
+
|
|
476
557
|
try:
|
|
477
558
|
# H3: stream over the shared client (pool / keep-alive / TLS
|
|
478
559
|
# reuse). Only the ``stream(...)`` context is scoped here; the
|
|
@@ -512,6 +593,7 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
512
593
|
current_event = None
|
|
513
594
|
data_lines = []
|
|
514
595
|
continue
|
|
596
|
+
terminator.observe(current_event, data_obj)
|
|
515
597
|
for out_event in self._process_stream_event_for_filters(
|
|
516
598
|
AnthropicStreamEvent(type=current_event, data=data_obj),
|
|
517
599
|
chains=filter_chains,
|
|
@@ -540,12 +622,37 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
540
622
|
except json.JSONDecodeError:
|
|
541
623
|
data_obj = None
|
|
542
624
|
if data_obj is not None:
|
|
625
|
+
terminator.observe(current_event, data_obj)
|
|
543
626
|
for out_event in self._process_stream_event_for_filters(
|
|
544
627
|
AnthropicStreamEvent(type=current_event, data=data_obj),
|
|
545
628
|
chains=filter_chains,
|
|
546
629
|
logged_flag=logged_flag,
|
|
547
630
|
):
|
|
548
631
|
yield out_event
|
|
632
|
+
|
|
633
|
+
# v2.15.0 (stream-truncation): the upstream line iterator is
|
|
634
|
+
# exhausted. Reaching here with no terminator observed means the
|
|
635
|
+
# HTTP body ended cleanly while the Anthropic message was still
|
|
636
|
+
# open — the exact case transport-level error handling below
|
|
637
|
+
# cannot see (``httpx.RemoteProtocolError`` and timeouts already
|
|
638
|
+
# raise). Under ``off`` this block is inert, which is what keeps
|
|
639
|
+
# the default byte-for-byte identical to v2.14.0.
|
|
640
|
+
if truncation_action != "off" and not terminator.saw_terminator:
|
|
641
|
+
log_stream_truncation_detected(
|
|
642
|
+
logger,
|
|
643
|
+
self.name,
|
|
644
|
+
action=truncation_action,
|
|
645
|
+
wire="anthropic",
|
|
646
|
+
events_forwarded=terminator.events,
|
|
647
|
+
saw_stream_start=terminator.saw_start,
|
|
648
|
+
tool_call_in_flight=terminator.tool_call_in_flight,
|
|
649
|
+
)
|
|
650
|
+
if truncation_action == "error":
|
|
651
|
+
raise StreamTruncatedError(
|
|
652
|
+
"upstream stream truncated (no message_stop terminator)",
|
|
653
|
+
provider=self.name,
|
|
654
|
+
tool_call_in_flight=terminator.tool_call_in_flight,
|
|
655
|
+
)
|
|
549
656
|
except httpx.TimeoutException as exc:
|
|
550
657
|
raise AdapterError(
|
|
551
658
|
f"timeout streaming from {url}", provider=self.name, retryable=True
|
|
@@ -137,6 +137,49 @@ class AdapterError(CodeRouterError):
|
|
|
137
137
|
return f"[{self.provider}{sc}] {super().__str__()}"
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
# v2.15.0 (stream-truncation): raised by an adapter when the upstream SSE
|
|
141
|
+
# stream ended *without* its protocol terminator (``message_stop`` on the
|
|
142
|
+
# Anthropic wire, ``data: [DONE]`` / a ``finish_reason`` on the OpenAI wire).
|
|
143
|
+
#
|
|
144
|
+
# HTTP-level breakage (timeout, ``httpx.RemoteProtocolError``) already became a
|
|
145
|
+
# plain ``AdapterError`` in both adapters. This subclass covers the layer
|
|
146
|
+
# mismatch the transport cannot see: the HTTP body ended cleanly, but the *LLM
|
|
147
|
+
# protocol* carried inside it was still mid-message.
|
|
148
|
+
#
|
|
149
|
+
# It subclasses ``AdapterError`` on purpose — every existing engine branch
|
|
150
|
+
# (``except AdapterError``) keeps working untouched, and the L2/L4/L5/L6 guards
|
|
151
|
+
# learn from the failure automatically. The subclass only adds identity, so
|
|
152
|
+
# ``classify_adapter_error`` can label the hop ``stream-truncated`` instead of
|
|
153
|
+
# the generic ``upstream-error``.
|
|
154
|
+
#
|
|
155
|
+
# Raised only when the active profile sets ``stream_truncation_action: error``.
|
|
156
|
+
# Under ``off`` (the default) and ``warn`` the adapter returns normally and the
|
|
157
|
+
# legacy terminator-synthesis path in ``coderouter.translation.convert`` (H6 /
|
|
158
|
+
# M9) runs byte-for-byte as before.
|
|
159
|
+
class StreamTruncatedError(AdapterError):
|
|
160
|
+
"""Upstream SSE stream ended without its protocol terminator event."""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
message: str,
|
|
165
|
+
*,
|
|
166
|
+
provider: str,
|
|
167
|
+
tool_call_in_flight: bool = False,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Construct a truncation error.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
message: Human-readable reason. Never contains upstream body text.
|
|
173
|
+
provider: The ``ProviderConfig.name`` whose stream was cut.
|
|
174
|
+
tool_call_in_flight: True when the stream was cut while a
|
|
175
|
+
``tool_use`` / ``tool_calls`` block was still open, i.e. the
|
|
176
|
+
accumulated argument JSON is certainly incomplete. Carried for
|
|
177
|
+
observability only — it never changes control flow.
|
|
178
|
+
"""
|
|
179
|
+
super().__init__(message, provider=provider, status_code=None, retryable=True)
|
|
180
|
+
self.tool_call_in_flight = tool_call_in_flight
|
|
181
|
+
|
|
182
|
+
|
|
140
183
|
# v0.6-B: per-call overrides resolved from the active profile. The engine
|
|
141
184
|
# builds one instance per request (since a profile is invariant across its
|
|
142
185
|
# chain) and threads it through every adapter call on that chain. Adapters
|
|
@@ -150,6 +193,13 @@ class AdapterError(CodeRouterError):
|
|
|
150
193
|
# - ``append_system_prompt=""`` is a meaningful explicit value: "for
|
|
151
194
|
# this profile, clear the provider's directive". The adapter must
|
|
152
195
|
# distinguish ``None`` (no override) from ``""`` (override-to-empty).
|
|
196
|
+
#
|
|
197
|
+
# v2.15.0 (stream-truncation): ``stream_truncation_action`` rides the same
|
|
198
|
+
# channel. It is a *profile*-level knob but has to be read inside the adapter's
|
|
199
|
+
# SSE parse loop, and ``ProviderCallOverrides`` is the only object the engine
|
|
200
|
+
# already threads into every adapter call. Its default of ``"off"`` keeps
|
|
201
|
+
# ``ProviderCallOverrides()`` (and every legacy call site that passes nothing)
|
|
202
|
+
# on the pre-v2.15.0 behavior.
|
|
153
203
|
class ProviderCallOverrides(BaseModel):
|
|
154
204
|
"""Per-call provider overrides, resolved from the active profile."""
|
|
155
205
|
|
|
@@ -157,6 +207,7 @@ class ProviderCallOverrides(BaseModel):
|
|
|
157
207
|
|
|
158
208
|
timeout_s: float | None = None
|
|
159
209
|
append_system_prompt: str | None = None
|
|
210
|
+
stream_truncation_action: Literal["off", "warn", "error"] = "off"
|
|
160
211
|
|
|
161
212
|
|
|
162
213
|
class BaseAdapter(ABC):
|
|
@@ -236,6 +287,20 @@ class BaseAdapter(ABC):
|
|
|
236
287
|
return overrides.append_system_prompt or None
|
|
237
288
|
return self.config.append_system_prompt
|
|
238
289
|
|
|
290
|
+
def effective_stream_truncation_action(
|
|
291
|
+
self, overrides: ProviderCallOverrides | None
|
|
292
|
+
) -> str:
|
|
293
|
+
"""v2.15.0: resolve ``stream_truncation_action`` for this call.
|
|
294
|
+
|
|
295
|
+
There is no per-provider counterpart — the knob lives on the profile
|
|
296
|
+
only — so this is just a null-safe read. ``None`` overrides (legacy
|
|
297
|
+
call sites, unit tests constructing adapters directly) resolve to
|
|
298
|
+
``"off"``, which is the pre-v2.15.0 behavior.
|
|
299
|
+
"""
|
|
300
|
+
if overrides is None:
|
|
301
|
+
return "off"
|
|
302
|
+
return overrides.stream_truncation_action
|
|
303
|
+
|
|
239
304
|
@abstractmethod
|
|
240
305
|
async def healthcheck(self) -> bool:
|
|
241
306
|
"""Lightweight check that the upstream is reachable. Return True if healthy."""
|
|
@@ -28,12 +28,14 @@ from coderouter.adapters.base import (
|
|
|
28
28
|
ChatResponse,
|
|
29
29
|
ProviderCallOverrides,
|
|
30
30
|
StreamChunk,
|
|
31
|
+
StreamTruncatedError,
|
|
31
32
|
)
|
|
32
|
-
from coderouter.
|
|
33
|
+
from coderouter.credentials import resolve_provider_credential
|
|
33
34
|
from coderouter.logging import (
|
|
34
35
|
get_logger,
|
|
35
36
|
log_capability_degraded,
|
|
36
37
|
log_output_filter_applied,
|
|
38
|
+
log_stream_truncation_detected,
|
|
37
39
|
)
|
|
38
40
|
from coderouter.output_filters import OutputFilterChain
|
|
39
41
|
|
|
@@ -105,7 +107,10 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
105
107
|
"Content-Type": "application/json",
|
|
106
108
|
"User-Agent": "CodeRouter/0.1",
|
|
107
109
|
}
|
|
108
|
-
|
|
110
|
+
# v2.14.0: resolve_provider_credential covers api_key_env (the
|
|
111
|
+
# historical path, unchanged) and credential.source=cli_session,
|
|
112
|
+
# which borrows the token a vendor CLI already wrote to disk.
|
|
113
|
+
api_key = resolve_provider_credential(self.config)
|
|
109
114
|
if api_key:
|
|
110
115
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
111
116
|
return headers
|
|
@@ -344,6 +349,20 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
344
349
|
# Captured for the closing flush chunk (if any): reuse the last
|
|
345
350
|
# seen chunk's id/model so the flush emission looks native.
|
|
346
351
|
last_chunk_template: dict[str, Any] | None = None
|
|
352
|
+
|
|
353
|
+
# v2.15.0 (stream-truncation): the loop below used to treat "saw
|
|
354
|
+
# [DONE]" and "the line iterator ran out" as the same event. These
|
|
355
|
+
# three flags are the memory that tells them apart.
|
|
356
|
+
#
|
|
357
|
+
# A terminator is EITHER ``data: [DONE]`` OR a ``finish_reason`` on
|
|
358
|
+
# any choice. Accepting ``finish_reason`` matters: a provider that
|
|
359
|
+
# emits a complete message and then closes without the ``[DONE]``
|
|
360
|
+
# sentinel is not truncated, and flagging it would be a false
|
|
361
|
+
# positive. See ``stream_truncation_action`` in ``config/schemas.py``.
|
|
362
|
+
truncation_action = self.effective_stream_truncation_action(overrides)
|
|
363
|
+
saw_terminator = False
|
|
364
|
+
chunks_seen = 0
|
|
365
|
+
tool_call_in_flight = False
|
|
347
366
|
try:
|
|
348
367
|
# H3: stream over the shared client so the connection pool /
|
|
349
368
|
# keep-alive / TLS session are reused. Only the ``stream(...)``
|
|
@@ -373,11 +392,31 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
373
392
|
continue
|
|
374
393
|
data_str = line[len("data:") :].strip()
|
|
375
394
|
if data_str == "[DONE]":
|
|
395
|
+
# v2.15.0: the sentinel — this stream ended on purpose.
|
|
396
|
+
saw_terminator = True
|
|
376
397
|
break
|
|
377
398
|
try:
|
|
378
399
|
payload_obj = json.loads(data_str)
|
|
379
400
|
except json.JSONDecodeError:
|
|
380
401
|
continue # skip malformed chunks rather than abort
|
|
402
|
+
# v2.15.0 (stream-truncation): fold this chunk into the
|
|
403
|
+
# terminator state before any filtering / validation, so a
|
|
404
|
+
# chunk that is later dropped as malformed still counts as
|
|
405
|
+
# "the upstream got this far".
|
|
406
|
+
chunks_seen += 1
|
|
407
|
+
for _choice in payload_obj.get("choices") or []:
|
|
408
|
+
if not isinstance(_choice, dict):
|
|
409
|
+
continue
|
|
410
|
+
if _choice.get("finish_reason"):
|
|
411
|
+
# A finish_reason means the message is complete
|
|
412
|
+
# even if the [DONE] sentinel never arrives, and
|
|
413
|
+
# it also closes any tool call that was open.
|
|
414
|
+
saw_terminator = True
|
|
415
|
+
tool_call_in_flight = False
|
|
416
|
+
elif isinstance(_choice.get("delta"), dict) and _choice[
|
|
417
|
+
"delta"
|
|
418
|
+
].get("tool_calls"):
|
|
419
|
+
tool_call_in_flight = True
|
|
381
420
|
if strip_reasoning:
|
|
382
421
|
stripped = _strip_reasoning_field(
|
|
383
422
|
payload_obj.get("choices"), delta_key=True
|
|
@@ -443,6 +482,30 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
443
482
|
streaming=True,
|
|
444
483
|
)
|
|
445
484
|
output_filter_logged = True
|
|
485
|
+
|
|
486
|
+
# v2.15.0 (stream-truncation): decided AFTER the filter flush on
|
|
487
|
+
# purpose — the flush is the last chance to hand the caller every
|
|
488
|
+
# safe byte the filter chain was still holding, and under
|
|
489
|
+
# ``partial_stitch_action: surface`` those bytes end up in the
|
|
490
|
+
# partial content the client receives. Under ``off`` this block is
|
|
491
|
+
# inert, keeping the default byte-for-byte identical to v2.14.0.
|
|
492
|
+
if truncation_action != "off" and not saw_terminator:
|
|
493
|
+
log_stream_truncation_detected(
|
|
494
|
+
logger,
|
|
495
|
+
self.name,
|
|
496
|
+
action=truncation_action,
|
|
497
|
+
wire="openai",
|
|
498
|
+
events_forwarded=chunks_seen,
|
|
499
|
+
saw_stream_start=chunks_seen > 0,
|
|
500
|
+
tool_call_in_flight=tool_call_in_flight,
|
|
501
|
+
)
|
|
502
|
+
if truncation_action == "error":
|
|
503
|
+
raise StreamTruncatedError(
|
|
504
|
+
"upstream stream truncated "
|
|
505
|
+
"(no [DONE] sentinel and no finish_reason)",
|
|
506
|
+
provider=self.name,
|
|
507
|
+
tool_call_in_flight=tool_call_in_flight,
|
|
508
|
+
)
|
|
446
509
|
except httpx.TimeoutException as exc:
|
|
447
510
|
raise AdapterError(
|
|
448
511
|
f"timeout streaming from {url}", provider=self.name, retryable=True
|