coderouter-cli 2.16.0__tar.gz → 2.17.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.16.0 → coderouter_cli-2.17.0}/CHANGELOG.md +47 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/PKG-INFO +1 -1
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/adapters/base.py +17 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/config/schemas.py +22 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/anthropic_errors.py +24 -5
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/anthropic_routes.py +12 -4
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/fallback.py +94 -6
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/fallback_trace.py +8 -1
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/README.en.md +1 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/README.md +1 -0
- coderouter_cli-2.17.0/docs/concepts/commit-point.en.md +68 -0
- coderouter_cli-2.17.0/docs/concepts/commit-point.md +70 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/pyproject.toml +1 -1
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_anthropic_errors.py +30 -1
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tool_search.py +1 -0
- coderouter_cli-2.17.0/tests/test_ttft_timeout.py +237 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/.gitignore +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/LICENSE +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/README.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/README.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/__main__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/adapters/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/adapters/anthropic_native.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/adapters/openai_compat.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/adapters/registry.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/cli.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/cli_stats.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/config/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/config/capability_registry.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/config/env_file.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/config/loader.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/cost.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/credentials.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/data/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/data/model-capabilities.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/doctor.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/doctor_apply.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/doctor_claude_code.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/env_security.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/errors.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/gguf_introspect.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/_fingerprint.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/backend_health.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/context_budget.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/continuous_probe.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/drift_actions.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/drift_detection.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/memory_budget.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/memory_pressure.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/self_healing.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/guards/tool_loop.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/hardware.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/app.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/dashboard_routes.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/launcher_routes.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/metrics_routes.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/openai_routes.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/tool_search.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/ingress/ui_auth.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/language_tax.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/launcher_devices.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/launcher_speculative.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/launcher_swap.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/logging.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/metrics/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/metrics/collector.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/metrics/prometheus.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/output_filters.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/plugins/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/plugins/base.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/plugins/loader.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/plugins/registry.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/py.typed +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/rollback.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/adaptive.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/auto_router.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/budget.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/routing/capability.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/secret_redaction.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/state/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/state/audit_log.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/state/replay.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/state/request_log.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/state/store.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/state/suggest_rules.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/token_estimation.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/token_estimation_accurate.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/translation/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/translation/anthropic.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/translation/convert.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/translation/tool_repair.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/coderouter/vscode_init.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/assets/dashboard-demo.png +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/claude-code-llamacpp-vllm.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/claude-code-llamacpp-vllm.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/external-agents.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/external-agents.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/gguf_dl.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/gguf_dl.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/hf-ollama-models.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/hf-ollama-models.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/install-backends.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/install-backends.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/launcher-quickstart.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/launcher-quickstart.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/launcher.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/launcher.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/llamacpp-direct.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/llamacpp-direct.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/lmstudio-direct.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/lmstudio-direct.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/backends/verify-ollama-0.23.1.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/architecture.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/architecture.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/context-budget.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/context-budget.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/continuous-probing.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/continuous-probing.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/drift-detection.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/drift-detection.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/partial-stitch.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/partial-stitch.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/stream-truncation.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/concepts/stream-truncation.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/agent-cli-plugin-extraction.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/external-agents-adapter.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/launcher-model-swap.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/launcher-multi-build.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/orchestration-companion.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/v1.5-dashboard-mockup.html +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/v1.6-auto-router-verification.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/designs/v1.6-auto-router.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/free-tier-guide.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/free-tier-guide.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/language-tax.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/language-tax.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/remote-access.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/remote-access.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/security.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/security.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/subagent-routing.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/subagent-routing.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/troubleshooting.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/troubleshooting.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/usage-guide.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/usage-guide.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/guides/vscode.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/low-memory-integration.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/low-memory-integration.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/openrouter-roster/CHANGES.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/openrouter-roster/README.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/openrouter-roster/latest.json +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v0.4.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v0.5-verify.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v0.5.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v0.6.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v0.7.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v1.0-verify.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/retrospectives/v1.0.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/start/quickstart.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/start/quickstart.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/start/when-do-i-need-coderouter.en.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/docs/start/when-do-i-need-coderouter.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/.env.example +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/README.md +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers-agent-cli.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers-multiagent.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.cli-session.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.context-budget-test.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.llamacpp-vllm.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.nvidia-nim.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.ollama-auto-custom.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.ollama-auto.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.ollama-free-chain.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.opusplan.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.raspberrypi.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.swap.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/examples/providers.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/demo_traffic.sh +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/openrouter_roster_diff.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/smoke_v2_2.sh +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/verify-providers.yaml +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/verify_ollama_0_23.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/verify_v0_5.sh +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/scripts/verify_v1_0.sh +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/__init__.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/conftest.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_adapter_anthropic.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_agent_cli.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_audit_log.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_auto_router.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_auto_router_cjk.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_backend_health.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_budget.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_capability.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_capability_degraded_payload.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_capability_registry.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_capability_registry_cache_control.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_capability_tool_choice.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_claude_code_suitability.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_cli.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_cli_bind_warning.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_cli_stats.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_config.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_config_cwd_warning.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_config_shim_actions.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_context_budget.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_continuous_probe.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_credentials.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_dashboard_endpoint.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_doctor.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_doctor_apply.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_doctor_cache_probe.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_doctor_claude_code.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_drift_actions.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_drift_detection.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_drift_detection_integration.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_env_file.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_env_security.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_errors.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_examples_yaml.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_examples_yaml_roundtrip.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_anthropic.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_cache_control.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_cache_observed.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_cache_strip.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_empty_response.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_misconfig_warn.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_paid_gate.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_reason.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_thinking.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_tool_choice_emulate.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fallback_tool_integrity.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_h1_prometheus_drift.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_h2_h6_translation.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_h3_client_reuse.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_h4_h5_guards.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_h7_probe_restore.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_h8_launcher_auth.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_mA_routing.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_mB_adapters.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_mC_convert.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_mD_logging_metrics.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_mE_config_validation.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_mF_ingress.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_fix_v213_launcher_hardening.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_gguf_introspect.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_guards_tool_loop.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_hardware.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_import_hygiene.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_ingress_anthropic.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_ingress_count_tokens.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_ingress_profile.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_language_tax.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_language_tax_integration.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_backend_variants.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_config_bench.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_devices.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_devices_detect.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_devices_routes.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_gui_devices.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_gui_events.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_gui_readiness.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_gui_sweep_close.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_mtp.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_mtp_fallback.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_provider_sync.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_readiness_restart.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_swap.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_swap_review.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_variant_config.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_variant_gui.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_variant_routes.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_launcher_variant_sweep.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_memory_budget.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_memory_pressure.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_cache.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_collector.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_cost.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_endpoint.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_jsonl.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_prometheus.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_metrics_prometheus_cache.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_models_passthrough.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_openai_compat.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_openrouter_roster_diff.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_output_filters.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_output_filters_adapters.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_packaging.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_partial_stitch.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_plugin_adapter.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_plugins_integration.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_plugins_loader.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_plugins_registry.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_reasoning_strip.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_repair_byte_fallback.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_request_log.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_role_normalization.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_rollback.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_routing_adaptive.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_secret_redaction.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_self_healing.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_setup_sh.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_state_store.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_stop_reason_forward_compat.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_stream_truncation.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_token_estimation.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_token_estimation_accurate.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tokens_saved_metric.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tool_repair.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tool_repair_differential.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tool_repair_lenient.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tool_repair_r4.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_tool_repair_scanners.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_toolrepair_bench.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_translation_anthropic.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_translation_reverse.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_ui_auth.py +0 -0
- {coderouter_cli-2.16.0 → coderouter_cli-2.17.0}/tests/test_vscode_init.py +0 -0
|
@@ -9,6 +9,53 @@ are kept verbatim where the Japanese text itself is the subject).
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [v2.17.0] — 2026-10-24 (time-to-first-token cut-off, retry signals)
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`ttft_timeout_s` (provider).** A streaming attempt that has not
|
|
17
|
+
produced its first real output (text / tool_use / thinking delta) within
|
|
18
|
+
this many seconds is abandoned before any byte reached the client, and
|
|
19
|
+
the chain moves on — e.g. a local llama-server still loading a model or
|
|
20
|
+
prefilling a long prompt while a cloud provider could answer now. Never
|
|
21
|
+
applied to the last provider of a chain (nothing to fall back to), nor to
|
|
22
|
+
non-streaming requests (`timeout_s` covers those), nor to the
|
|
23
|
+
`/v1/chat/completions` route. It is also skipped for the tool-turn
|
|
24
|
+
downgrade (`kind: openai_compat` with tools, which calls the upstream
|
|
25
|
+
non-streaming so tool calls can be repaired): there the "first output" is
|
|
26
|
+
the whole answer, and a deadline would discard slow but healthy replies —
|
|
27
|
+
use `kind: anthropic` (native `/v1/messages`) to get the cut-off for a
|
|
28
|
+
local server. It always covers the
|
|
29
|
+
wait for the first event; covering the gap between the opening events and
|
|
30
|
+
the first real output needs `empty_response_action: fallback`, which is
|
|
31
|
+
what withholds those events from the client. Abandoned attempts are
|
|
32
|
+
ordinary retryable failures: new fallback reason `ttft-timeout` (detail
|
|
33
|
+
`no-first-token`) and the self-healing guards learn from them. A cut that
|
|
34
|
+
happens after the opening events is handled like a truncated stream: the
|
|
35
|
+
withheld, unterminated preamble is never flushed to the client if the
|
|
36
|
+
rest of the chain also fails, and the abandoned upstream stream is closed
|
|
37
|
+
immediately. Default
|
|
38
|
+
`None` (off). Docs: `docs/concepts/commit-point.md` / `.en.md`.
|
|
39
|
+
- **`x-should-retry` on `/v1/messages` errors.** Every Anthropic-shaped
|
|
40
|
+
error CodeRouter produces now says whether retrying can help — the header
|
|
41
|
+
Claude Code reads, with `retry-after`, to decide whether to spend its
|
|
42
|
+
retry budget: `false` for 400 / 413 (including tool-search and
|
|
43
|
+
`context_budget_action: reject` refusals), `true` for 429, and for 502
|
|
44
|
+
`true` only when at least one upstream failure was retryable (a chain that
|
|
45
|
+
failed purely on 401/403 fails the same way on every retry).
|
|
46
|
+
- **Docs: the commit point** (`docs/concepts/commit-point.md` / `.en.md`) —
|
|
47
|
+
which failures before the body ships CodeRouter can still move to another
|
|
48
|
+
provider, which it cannot, and the error each chain failure becomes.
|
|
49
|
+
Recommends `empty_response_action: fallback` for Claude Code profiles.
|
|
50
|
+
|
|
51
|
+
### Known limits
|
|
52
|
+
|
|
53
|
+
- No keep-alive `ping` is sent while opening events are withheld; keep
|
|
54
|
+
`ttft_timeout_s` below the client's idle timeout
|
|
55
|
+
(`CLAUDE_STREAM_IDLE_TIMEOUT_MS` for Claude Code).
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
12
59
|
## [v2.16.0] — 2026-10-24 (Claude Code 2.1.3xx behind a gateway)
|
|
13
60
|
|
|
14
61
|
Claude Code 2.1.285–2.1.309 changed several defaults for sessions behind a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.17.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
|
|
@@ -200,6 +200,23 @@ class StreamTruncatedError(AdapterError):
|
|
|
200
200
|
self.tool_call_in_flight = tool_call_in_flight
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
# v2.17.0: the provider did not produce its first real streamed output within
|
|
204
|
+
# ``ProviderConfig.ttft_timeout_s``. Raised by the engine (not the adapter),
|
|
205
|
+
# before any byte reached the client, so the chain falls back normally and the
|
|
206
|
+
# self-healing guards learn from it like any other retryable failure.
|
|
207
|
+
class TTFTTimeoutError(AdapterError):
|
|
208
|
+
"""No first token within ``ttft_timeout_s``."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, *, provider: str, ttft_timeout_s: float) -> None:
|
|
211
|
+
super().__init__(
|
|
212
|
+
f"timeout: no first token within {ttft_timeout_s:g}s (ttft_timeout_s)",
|
|
213
|
+
provider=provider,
|
|
214
|
+
status_code=None,
|
|
215
|
+
retryable=True,
|
|
216
|
+
)
|
|
217
|
+
self.ttft_timeout_s = ttft_timeout_s
|
|
218
|
+
|
|
219
|
+
|
|
203
220
|
# v0.6-B: per-call overrides resolved from the active profile. The engine
|
|
204
221
|
# builds one instance per request (since a profile is invariant across its
|
|
205
222
|
# chain) and threads it through every adapter call on that chain. Adapters
|
|
@@ -550,6 +550,28 @@ class ProviderConfig(BaseModel):
|
|
|
550
550
|
description="If true, only used when ALLOW_PAID=true (plan.md §2.3).",
|
|
551
551
|
)
|
|
552
552
|
timeout_s: float = Field(default=30.0, ge=1.0, le=86400.0)
|
|
553
|
+
# v2.17.0: time-to-first-token cut-off for streaming requests. When the
|
|
554
|
+
# provider has not produced its first real output (text / tool_use /
|
|
555
|
+
# thinking delta) within this many seconds of the attempt starting, the
|
|
556
|
+
# attempt is abandoned and the chain moves on — before any byte reached
|
|
557
|
+
# the client. Never applied to the last provider of a chain (nothing to
|
|
558
|
+
# move on to), nor to non-streaming requests (``timeout_s`` covers
|
|
559
|
+
# those). Before the first *event* it always applies; between the first
|
|
560
|
+
# event and the first real output it needs
|
|
561
|
+
# ``empty_response_action: fallback`` on the profile, which is what
|
|
562
|
+
# withholds those opening events from the client. ``None`` (default)
|
|
563
|
+
# disables it. Typical use: a local llama-server still loading a model
|
|
564
|
+
# or chewing through a long prompt while a cloud fallback could answer.
|
|
565
|
+
ttft_timeout_s: float | None = Field(
|
|
566
|
+
default=None,
|
|
567
|
+
gt=0.0,
|
|
568
|
+
le=3600.0,
|
|
569
|
+
description=(
|
|
570
|
+
"v2.17.0: seconds to wait for the first real streamed output "
|
|
571
|
+
"before falling back to the next provider (not applied to the "
|
|
572
|
+
"last provider in a chain). None disables."
|
|
573
|
+
),
|
|
574
|
+
)
|
|
553
575
|
|
|
554
576
|
# v2.6 language-tax track: path to a LOCAL ``tokenizer.json`` for this
|
|
555
577
|
# provider's model, used to measure the CJK over-count vs the char/4
|
|
@@ -33,6 +33,13 @@ terminal error 429 429 ``rate_limit_error``
|
|
|
33
33
|
anything else 502 ``api_error``
|
|
34
34
|
==================================== ====== ========================
|
|
35
35
|
|
|
36
|
+
v2.17.0: every mapped response also carries ``x-should-retry`` — the header
|
|
37
|
+
Claude Code reads (with ``retry-after``) to decide whether to spend its retry
|
|
38
|
+
budget. ``false`` for the 4xx rows (resending the same request cannot
|
|
39
|
+
help), ``true`` for 429, and for 502 ``true`` only when at least one
|
|
40
|
+
upstream failure was retryable — a chain that failed purely on, say, 401s
|
|
41
|
+
will fail the same way on every retry.
|
|
42
|
+
|
|
36
43
|
Upstream 401 / 403 deliberately fall into the last row: they describe
|
|
37
44
|
CodeRouter's *own* upstream credentials, not the client's, and handing a
|
|
38
45
|
401 to Claude Code would send the user to ``/login`` for nothing.
|
|
@@ -164,17 +171,26 @@ def map_no_providers_error(exc: NoProvidersAvailableError) -> AnthropicErrorResp
|
|
|
164
171
|
msg = upstream_message(err)
|
|
165
172
|
if not msg.lower().startswith(PROMPT_TOO_LONG_PREFIX):
|
|
166
173
|
msg = f"{PROMPT_TOO_LONG_PREFIX}: {msg}"
|
|
167
|
-
return AnthropicErrorResponse(
|
|
174
|
+
return AnthropicErrorResponse(
|
|
175
|
+
400, "invalid_request_error", msg, {"x-should-retry": "false"}
|
|
176
|
+
)
|
|
168
177
|
|
|
169
178
|
terminal = errors[-1] if errors else None
|
|
170
179
|
if terminal is not None and terminal.status_code is not None:
|
|
171
180
|
status = terminal.status_code
|
|
172
181
|
if status in (400, 422) and not terminal.retryable:
|
|
173
|
-
return AnthropicErrorResponse(
|
|
182
|
+
return AnthropicErrorResponse(
|
|
183
|
+
400,
|
|
184
|
+
"invalid_request_error",
|
|
185
|
+
upstream_message(terminal),
|
|
186
|
+
{"x-should-retry": "false"},
|
|
187
|
+
)
|
|
174
188
|
if status == 413:
|
|
175
|
-
return AnthropicErrorResponse(
|
|
189
|
+
return AnthropicErrorResponse(
|
|
190
|
+
413, "request_too_large", upstream_message(terminal), {"x-should-retry": "false"}
|
|
191
|
+
)
|
|
176
192
|
if status == 429:
|
|
177
|
-
headers: dict[str, str] = {}
|
|
193
|
+
headers: dict[str, str] = {"x-should-retry": "true"}
|
|
178
194
|
retry_after = getattr(terminal, "retry_after", None)
|
|
179
195
|
if retry_after:
|
|
180
196
|
headers["retry-after"] = retry_after
|
|
@@ -182,4 +198,7 @@ def map_no_providers_error(exc: NoProvidersAvailableError) -> AnthropicErrorResp
|
|
|
182
198
|
429, "rate_limit_error", upstream_message(terminal), headers
|
|
183
199
|
)
|
|
184
200
|
|
|
185
|
-
|
|
201
|
+
retry = any(e.retryable for e in errors)
|
|
202
|
+
return AnthropicErrorResponse(
|
|
203
|
+
502, "api_error", redact(str(exc)), {"x-should-retry": "true" if retry else "false"}
|
|
204
|
+
)
|
|
@@ -276,7 +276,9 @@ async def messages(
|
|
|
276
276
|
)
|
|
277
277
|
if ts_action == "reject":
|
|
278
278
|
return _anthropic_error_response(
|
|
279
|
-
AnthropicErrorResponse(
|
|
279
|
+
AnthropicErrorResponse(
|
|
280
|
+
400, "invalid_request_error", REJECT_MESSAGE, {"x-should-retry": "false"}
|
|
281
|
+
)
|
|
280
282
|
)
|
|
281
283
|
if ts_action == "expand":
|
|
282
284
|
anth_req = expand_tool_search(anth_req)
|
|
@@ -290,7 +292,9 @@ async def messages(
|
|
|
290
292
|
# v2.16.0: ``context_budget_action: reject`` — Anthropic's own
|
|
291
293
|
# overflow answer, so Claude Code compacts the conversation.
|
|
292
294
|
return _anthropic_error_response(
|
|
293
|
-
AnthropicErrorResponse(
|
|
295
|
+
AnthropicErrorResponse(
|
|
296
|
+
400, "invalid_request_error", str(exc), {"x-should-retry": "false"}
|
|
297
|
+
)
|
|
294
298
|
)
|
|
295
299
|
|
|
296
300
|
if anth_req.stream:
|
|
@@ -362,7 +366,9 @@ async def messages(
|
|
|
362
366
|
# swap) — same answer as the pre-dispatch check above.
|
|
363
367
|
await _aclose_quietly(source)
|
|
364
368
|
return _anthropic_error_response(
|
|
365
|
-
AnthropicErrorResponse(
|
|
369
|
+
AnthropicErrorResponse(
|
|
370
|
+
400, "invalid_request_error", str(exc), {"x-should-retry": "false"}
|
|
371
|
+
)
|
|
366
372
|
)
|
|
367
373
|
except (ToolLoopBreakError, ToolCountExceededError, MidStreamError) as exc:
|
|
368
374
|
# Policy errors and mid-stream failures keep their existing
|
|
@@ -415,7 +421,9 @@ async def messages(
|
|
|
415
421
|
except ContextBudgetRejectError as exc:
|
|
416
422
|
# See the streaming branch: the engine can re-run the guard.
|
|
417
423
|
return _anthropic_error_response(
|
|
418
|
-
AnthropicErrorResponse(
|
|
424
|
+
AnthropicErrorResponse(
|
|
425
|
+
400, "invalid_request_error", str(exc), {"x-should-retry": "false"}
|
|
426
|
+
)
|
|
419
427
|
)
|
|
420
428
|
except NoProvidersAvailableError as exc:
|
|
421
429
|
# v2.16.0: Anthropic-shaped error with the upstream's own wording
|
|
@@ -47,6 +47,7 @@ from coderouter.adapters.base import (
|
|
|
47
47
|
ProviderCallOverrides,
|
|
48
48
|
StreamChunk,
|
|
49
49
|
StreamTruncatedError,
|
|
50
|
+
TTFTTimeoutError,
|
|
50
51
|
)
|
|
51
52
|
from coderouter.adapters.registry import build_adapter
|
|
52
53
|
from coderouter.config.schemas import CodeRouterConfig, ProviderConfig
|
|
@@ -111,6 +112,7 @@ from coderouter.routing.fallback_trace import (
|
|
|
111
112
|
REASON_PAID_GATE,
|
|
112
113
|
REASON_SELF_HEALING_EXCLUDED,
|
|
113
114
|
REASON_STREAM_TRUNCATED,
|
|
115
|
+
REASON_TTFT_TIMEOUT,
|
|
114
116
|
REASON_UNKNOWN_PROVIDER,
|
|
115
117
|
FallbackTrace,
|
|
116
118
|
begin_fallback_trace,
|
|
@@ -898,6 +900,38 @@ def _emit_cache_observed_streaming(
|
|
|
898
900
|
)
|
|
899
901
|
|
|
900
902
|
|
|
903
|
+
async def _await_within_ttft(
|
|
904
|
+
awaitable: Any,
|
|
905
|
+
*,
|
|
906
|
+
deadline: float | None,
|
|
907
|
+
provider: str,
|
|
908
|
+
ttft_timeout_s: float | None,
|
|
909
|
+
) -> Any:
|
|
910
|
+
"""v2.17.0: await ``awaitable`` but give up at the TTFT ``deadline``.
|
|
911
|
+
|
|
912
|
+
``deadline`` is a ``time.monotonic()`` instant (``None`` = no limit). On
|
|
913
|
+
expiry raises :class:`TTFTTimeoutError` — a retryable ``AdapterError``, so
|
|
914
|
+
every existing pre-content fallback branch handles it unchanged. The
|
|
915
|
+
cancelled await unwinds the adapter's generator, which releases its
|
|
916
|
+
upstream connection.
|
|
917
|
+
"""
|
|
918
|
+
if deadline is None:
|
|
919
|
+
return await awaitable
|
|
920
|
+
remaining = deadline - time.monotonic()
|
|
921
|
+
if remaining <= 0:
|
|
922
|
+
close = getattr(awaitable, "close", None)
|
|
923
|
+
if close is not None:
|
|
924
|
+
close()
|
|
925
|
+
raise TTFTTimeoutError(provider=provider, ttft_timeout_s=ttft_timeout_s or 0.0)
|
|
926
|
+
try:
|
|
927
|
+
async with asyncio.timeout(remaining):
|
|
928
|
+
return await awaitable
|
|
929
|
+
except TimeoutError as exc:
|
|
930
|
+
raise TTFTTimeoutError(
|
|
931
|
+
provider=provider, ttft_timeout_s=ttft_timeout_s or 0.0
|
|
932
|
+
) from exc
|
|
933
|
+
|
|
934
|
+
|
|
901
935
|
class NoProvidersAvailableError(CodeRouterError):
|
|
902
936
|
"""Raised when every provider in the chain has failed (or was filtered out)."""
|
|
903
937
|
|
|
@@ -3354,7 +3388,7 @@ class FallbackEngine:
|
|
|
3354
3388
|
# keeps the default byte-for-byte identical to v2.14.0.
|
|
3355
3389
|
last_empty_stream_truncated = False
|
|
3356
3390
|
|
|
3357
|
-
for adapter, will_degrade in chain:
|
|
3391
|
+
for chain_idx, (adapter, will_degrade) in enumerate(chain):
|
|
3358
3392
|
is_native = isinstance(adapter, AnthropicAdapter)
|
|
3359
3393
|
downgrading = (not is_native) and bool(request.tools)
|
|
3360
3394
|
effective_request = request
|
|
@@ -3414,6 +3448,14 @@ class FallbackEngine:
|
|
|
3414
3448
|
# only the non-streaming generate_anthropic recorded, so the
|
|
3415
3449
|
# busiest path produced no adaptive signal.
|
|
3416
3450
|
attempt_started = time.monotonic()
|
|
3451
|
+
# v2.17.0: TTFT cut-off. Never on the last provider — there is
|
|
3452
|
+
# nothing left to fall back to, so waiting is the better answer.
|
|
3453
|
+
ttft_s = getattr(adapter.config, "ttft_timeout_s", None)
|
|
3454
|
+
ttft_deadline = (
|
|
3455
|
+
attempt_started + ttft_s
|
|
3456
|
+
if ttft_s is not None and chain_idx < len(chain) - 1
|
|
3457
|
+
else None
|
|
3458
|
+
)
|
|
3417
3459
|
event_iter: AsyncIterator[AnthropicStreamEvent]
|
|
3418
3460
|
first: AnthropicStreamEvent
|
|
3419
3461
|
try:
|
|
@@ -3422,11 +3464,21 @@ class FallbackEngine:
|
|
|
3422
3464
|
# mypy narrows for stream_anthropic (not on BaseAdapter).
|
|
3423
3465
|
if isinstance(adapter, AnthropicAdapter):
|
|
3424
3466
|
event_iter = adapter.stream_anthropic(effective_request, overrides=overrides)
|
|
3425
|
-
first = await
|
|
3467
|
+
first = await _await_within_ttft(
|
|
3468
|
+
anext(event_iter),
|
|
3469
|
+
deadline=ttft_deadline,
|
|
3470
|
+
provider=adapter.name,
|
|
3471
|
+
ttft_timeout_s=ttft_s,
|
|
3472
|
+
)
|
|
3426
3473
|
elif downgrading:
|
|
3427
3474
|
# v0.3-D downgrade: run non-streaming, repair, replay.
|
|
3428
3475
|
chat_req = to_chat_request(effective_request)
|
|
3429
3476
|
chat_req.stream = False
|
|
3477
|
+
# v2.17.0: no TTFT deadline here. This branch is a
|
|
3478
|
+
# *non-streaming* call (the tool-turn downgrade), so its
|
|
3479
|
+
# "first output" is the whole answer — a TTFT cut would
|
|
3480
|
+
# become a total-generation limit and throw away slow
|
|
3481
|
+
# but healthy local replies. ``timeout_s`` bounds it.
|
|
3430
3482
|
chat_resp = await adapter.generate(chat_req, overrides=overrides)
|
|
3431
3483
|
# H-4: same as the non-streaming branch — keep the
|
|
3432
3484
|
# CPU-bound repair scan off the event loop.
|
|
@@ -3441,7 +3493,12 @@ class FallbackEngine:
|
|
|
3441
3493
|
event_iter = stream_chat_to_anthropic_events(
|
|
3442
3494
|
adapter.stream(chat_req, overrides=overrides)
|
|
3443
3495
|
)
|
|
3444
|
-
first = await
|
|
3496
|
+
first = await _await_within_ttft(
|
|
3497
|
+
anext(event_iter),
|
|
3498
|
+
deadline=ttft_deadline,
|
|
3499
|
+
provider=adapter.name,
|
|
3500
|
+
ttft_timeout_s=ttft_s,
|
|
3501
|
+
)
|
|
3445
3502
|
except StopAsyncIteration:
|
|
3446
3503
|
# M2: record the empty-stream failure.
|
|
3447
3504
|
self._adaptive.record_attempt(
|
|
@@ -3557,7 +3614,18 @@ class FallbackEngine:
|
|
|
3557
3614
|
yield buffered_ev
|
|
3558
3615
|
buffer = []
|
|
3559
3616
|
try:
|
|
3560
|
-
|
|
3617
|
+
if content_started:
|
|
3618
|
+
pending = await iterator.__anext__()
|
|
3619
|
+
else:
|
|
3620
|
+
# v2.17.0: the TTFT deadline also covers the
|
|
3621
|
+
# withheld preamble — first *real* output is
|
|
3622
|
+
# what counts, not the first event.
|
|
3623
|
+
pending = await _await_within_ttft(
|
|
3624
|
+
iterator.__anext__(),
|
|
3625
|
+
deadline=ttft_deadline,
|
|
3626
|
+
provider=adapter.name,
|
|
3627
|
+
ttft_timeout_s=ttft_s,
|
|
3628
|
+
)
|
|
3561
3629
|
except StopAsyncIteration:
|
|
3562
3630
|
break
|
|
3563
3631
|
except AdapterError as exc:
|
|
@@ -3609,8 +3677,28 @@ class FallbackEngine:
|
|
|
3609
3677
|
# message — so the hop is recorded as ``stream-truncated``
|
|
3610
3678
|
# and the empty-response log is skipped. The adapter has
|
|
3611
3679
|
# already emitted ``stream-truncation-detected``.
|
|
3612
|
-
|
|
3613
|
-
|
|
3680
|
+
#
|
|
3681
|
+
# v2.17.0: a TTFT cut-off lands here too, and its buffer
|
|
3682
|
+
# is just as unterminated (opening events, no
|
|
3683
|
+
# ``message_stop``). Treat it like a truncation so the
|
|
3684
|
+
# chain-exhausted flush below never replays it as a
|
|
3685
|
+
# "complete empty answer" — that would hand the client a
|
|
3686
|
+
# 200 stream that never ends. Close the abandoned upstream
|
|
3687
|
+
# stream now rather than waiting for GC.
|
|
3688
|
+
ttft_cut = isinstance(exc, TTFTTimeoutError)
|
|
3689
|
+
truncated = isinstance(exc, StreamTruncatedError) or ttft_cut
|
|
3690
|
+
if ttft_cut:
|
|
3691
|
+
aclose_iter = getattr(event_iter, "aclose", None)
|
|
3692
|
+
if aclose_iter is not None:
|
|
3693
|
+
with contextlib.suppress(Exception):
|
|
3694
|
+
await aclose_iter()
|
|
3695
|
+
trace.record_failure(
|
|
3696
|
+
adapter.name,
|
|
3697
|
+
REASON_TTFT_TIMEOUT,
|
|
3698
|
+
detail=describe_adapter_error(exc),
|
|
3699
|
+
stream=True,
|
|
3700
|
+
)
|
|
3701
|
+
elif truncated:
|
|
3614
3702
|
trace.record_failure(
|
|
3615
3703
|
adapter.name,
|
|
3616
3704
|
REASON_STREAM_TRUNCATED,
|
|
@@ -111,6 +111,8 @@ REASON_EMPTY_STREAM: Final = "empty-stream"
|
|
|
111
111
|
# ``connection`` (the transport itself broke). Produced only when the profile
|
|
112
112
|
# sets ``stream_truncation_action: error``.
|
|
113
113
|
REASON_STREAM_TRUNCATED: Final = "stream-truncated"
|
|
114
|
+
# v2.17.0: no first real output within the provider's ``ttft_timeout_s``.
|
|
115
|
+
REASON_TTFT_TIMEOUT: Final = "ttft-timeout"
|
|
114
116
|
|
|
115
117
|
# -- degrade reasons (pre-existing strings; never produce a hop) -----------
|
|
116
118
|
# Kept here so the whole ``reason`` vocabulary is readable in one place.
|
|
@@ -141,6 +143,7 @@ ATTEMPT_FAILURE_REASONS: Final[frozenset[str]] = frozenset(
|
|
|
141
143
|
REASON_EMPTY_RESPONSE,
|
|
142
144
|
REASON_EMPTY_STREAM,
|
|
143
145
|
REASON_STREAM_TRUNCATED,
|
|
146
|
+
REASON_TTFT_TIMEOUT,
|
|
144
147
|
}
|
|
145
148
|
)
|
|
146
149
|
|
|
@@ -207,6 +210,8 @@ def describe_adapter_error(exc: AdapterError) -> str:
|
|
|
207
210
|
# Not a transport fault — the HTTP body ended cleanly; it was the
|
|
208
211
|
# LLM protocol inside it that never closed.
|
|
209
212
|
return "no-terminator"
|
|
213
|
+
if reason == REASON_TTFT_TIMEOUT:
|
|
214
|
+
return "no-first-token"
|
|
210
215
|
return f"transport={reason}"
|
|
211
216
|
|
|
212
217
|
|
|
@@ -227,10 +232,12 @@ def classify_adapter_error(exc: AdapterError) -> str:
|
|
|
227
232
|
``adapters.base`` is a TYPE_CHECKING-only import at module scope here
|
|
228
233
|
(the runtime import order would otherwise cycle through the registry).
|
|
229
234
|
"""
|
|
230
|
-
from coderouter.adapters.base import StreamTruncatedError
|
|
235
|
+
from coderouter.adapters.base import StreamTruncatedError, TTFTTimeoutError
|
|
231
236
|
|
|
232
237
|
if isinstance(exc, StreamTruncatedError):
|
|
233
238
|
return REASON_STREAM_TRUNCATED
|
|
239
|
+
if isinstance(exc, TTFTTimeoutError):
|
|
240
|
+
return REASON_TTFT_TIMEOUT
|
|
234
241
|
status = exc.status_code
|
|
235
242
|
if status is None:
|
|
236
243
|
message = str(exc).lower()
|
|
@@ -90,6 +90,7 @@ How CodeRouter works and its reliability mechanisms.
|
|
|
90
90
|
- **partial-stitch** — Mid-stream partial stitching (v2.0-H) · [日本語](concepts/partial-stitch.md) · [English](concepts/partial-stitch.en.md)
|
|
91
91
|
- **continuous-probing** — Continuous probing (v2.0-I) · [日本語](concepts/continuous-probing.md) · [English](concepts/continuous-probing.en.md)
|
|
92
92
|
- **stream-truncation** — Stream truncation detection (v2.15.0) · [日本語](concepts/stream-truncation.md) · [English](concepts/stream-truncation.en.md)
|
|
93
|
+
- **commit-point** — Pre-content failures, TTFT cut-off and chain-failure errors (v2.16.0/v2.17.0) · [日本語](concepts/commit-point.md) · [English](concepts/commit-point.en.md)
|
|
93
94
|
- **low-memory-integration** — Integration guide for the proactive memory-budget guard on low-memory (8–16GB) hosts — VRAM/RAM detection, GGUF header introspection, and KV-cache-aware pre-dispatch fit decisions (`off`/`warn`/`fit`). **Implemented (v2.5.3)** · [日本語](low-memory-integration.md) · [English](low-memory-integration.en.md)
|
|
94
95
|
|
|
95
96
|
## 5. Design docs & records
|
|
@@ -95,6 +95,7 @@ CodeRouter の仕組みと信頼性機構。 / How CodeRouter works and its reli
|
|
|
95
95
|
- **partial-stitch** — ストリーム途中の部分ステッチ (v2.0-H) / Mid-stream partial stitching · [日本語](concepts/partial-stitch.md) · [English](concepts/partial-stitch.en.md)
|
|
96
96
|
- **continuous-probing** — 継続プロービング (v2.0-I) / Continuous probing · [日本語](concepts/continuous-probing.md) · [English](concepts/continuous-probing.en.md)
|
|
97
97
|
- **stream-truncation** — ストリーム断絶検知 (v2.15.0) / Stream truncation detection · [日本語](concepts/stream-truncation.md) · [English](concepts/stream-truncation.en.md)
|
|
98
|
+
- **commit-point** — 本文送出前の失敗の扱い・TTFT カットオフ・全滅時のエラー (v2.16.0/v2.17.0) / Pre-content failures, TTFT cut-off, chain-failure errors · [日本語](concepts/commit-point.md) · [English](concepts/commit-point.en.md)
|
|
98
99
|
- **low-memory-integration** — 低メモリ機(8–16GB)向けメモリ予算ガードの統合ガイド。VRAM/RAM 検出・GGUFヘッダ解析・KVキャッシュ試算による事前フィット判定(`off`/`warn`/`fit`)。**実装済み (v2.5.3)** / Integration guide for the proactive memory-budget guard on low-memory (8–16GB) hosts — VRAM/RAM detection, GGUF header introspection, and KV-cache-aware pre-dispatch fit decisions (`off`/`warn`/`fit`). **Implemented (v2.5.3)** · [日本語](low-memory-integration.md) · [English](low-memory-integration.en.md)
|
|
99
100
|
|
|
100
101
|
## 5. 設計資料・記録 / Design docs & records
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# The commit point — which failures before the body ships can be saved
|
|
2
|
+
|
|
3
|
+
> Applies to v2.16.0+ (`ttft_timeout_s` and `x-should-retry` are v2.17.0)
|
|
4
|
+
> 日本語: [commit-point.md](./commit-point.md)
|
|
5
|
+
|
|
6
|
+
A streaming response has a point of no return. Once even one byte of the body has reached the client, an upstream failure cannot be handed to another provider — the text already delivered and the new provider's text would not join up. CodeRouter calls that point the **commit point** and tries to send every failure that happens before it to the next provider.
|
|
7
|
+
|
|
8
|
+
## What can happen before the commit point, and what CodeRouter does
|
|
9
|
+
|
|
10
|
+
| Situation | Handling | Needs |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| Upstream refuses, times out, or answers 4xx/5xx (before the first event) | Next provider. If the whole chain fails: **an Anthropic-shaped error with a real HTTP status** (v2.16.0) | nothing |
|
|
13
|
+
| Upstream never produces its first token (model loading, long prompt) | Next provider once `ttft_timeout_s` expires (v2.17.0) | `ttft_timeout_s` on the provider |
|
|
14
|
+
| Opening events (`message_start` …) arrived but the stream ends or breaks before any content | Withhold the opening events from the client and move to the next provider | `empty_response_action: fallback` on the profile |
|
|
15
|
+
| The stream breaks **after** content shipped | **No switch.** Detect and report it (`stream_truncation_action`, v2.15.0), or close with the content received (`partial_stitch_action: surface`) | those settings |
|
|
16
|
+
|
|
17
|
+
CodeRouter cannot save the last row either. As far as we checked in 2026-10, LiteLLM and Bifrost do not switch after the body has shipped either.
|
|
18
|
+
|
|
19
|
+
## `empty_response_action: fallback` is the effective commit point
|
|
20
|
+
|
|
21
|
+
With this setting the engine holds back `message_start` and the other opening events and releases them together with the **first real output** (a text / tool_use / thinking delta). That moment is the commit point; anything that fails before it is moved to the next provider without the client seeing it.
|
|
22
|
+
|
|
23
|
+
Turn it on first when serving Claude Code:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
profiles:
|
|
27
|
+
- name: claude-code
|
|
28
|
+
providers: [local-llamacpp, openrouter-free]
|
|
29
|
+
empty_response_action: fallback
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## `ttft_timeout_s` — a deadline for the first token (v2.17.0)
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
providers:
|
|
36
|
+
- name: local-llamacpp
|
|
37
|
+
kind: anthropic # pass straight through to llama-server's /v1/messages
|
|
38
|
+
base_url: http://127.0.0.1:8080
|
|
39
|
+
model: ""
|
|
40
|
+
ttft_timeout_s: 20 # no first output within 20 s → next provider
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **Starts** when the attempt on that provider starts.
|
|
44
|
+
- **Stops** at the first real output; opening events alone do not stop it. Watching past the opening events needs `empty_response_action: fallback` (without it those events go to the client at once and the stream is committed).
|
|
45
|
+
- **Never applied to the last provider of a chain** — there is nothing to fall back to, so waiting is the better answer.
|
|
46
|
+
- Not applied to non-streaming requests (`timeout_s` bounds those).
|
|
47
|
+
- **Only the `/v1/messages` (Anthropic) streaming path** is covered; `/v1/chat/completions` is not.
|
|
48
|
+
- **Not applied to `kind: openai_compat` requests that carry tools** (Claude Code sends tools on almost every request). That combination calls the upstream non-streaming so tool calls can be repaired (the tool-turn downgrade), which makes the "first output" the whole answer; a deadline there would throw away slow but healthy local replies. To use the TTFT cut-off with a local llama.cpp / Ollama, connect with `kind: anthropic` (native `/v1/messages`) as in the example above. Note that CodeRouter's tool-call repair does not run on that route (repair lives on the OpenAI-translation path), so it suits models whose native tool calls are already well-formed.
|
|
49
|
+
- An abandoned attempt is an ordinary failure: `X-CodeRouter-Fallback-Reason: ttft-timeout`, and the self-healing guards learn from it.
|
|
50
|
+
|
|
51
|
+
Typical use: a local llama-server is still loading a model or chewing through a long prompt while a cloud provider could answer now. Start at two to three times your usual local TTFT; too short and every request escapes to the cloud.
|
|
52
|
+
|
|
53
|
+
## What a failed chain returns (v2.16.0 / v2.17.0)
|
|
54
|
+
|
|
55
|
+
| Upstream situation | HTTP | `error.type` | `x-should-retry` |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| Context overflow (any provider) | 400 | `invalid_request_error` (`prompt is too long: …`) | `false` |
|
|
58
|
+
| A 400 / 422 that retrying cannot fix | 400 | `invalid_request_error` (upstream wording) | `false` |
|
|
59
|
+
| 413 | 413 | `request_too_large` | `false` |
|
|
60
|
+
| 429 | 429 | `rate_limit_error` (upstream `retry-after` passed through) | `true` |
|
|
61
|
+
| Anything else (5xx, timeouts, 401/403, …) | 502 | `api_error` | `true` if at least one failure was retryable |
|
|
62
|
+
|
|
63
|
+
A streaming request whose chain fails before the first event gets this HTTP status too, not `200` with an in-stream `error` event. Claude Code compacts on `prompt is too long` and uses `x-should-retry` / `retry-after` to decide whether to retry.
|
|
64
|
+
|
|
65
|
+
## Known limits
|
|
66
|
+
|
|
67
|
+
- **Nothing reaches the client while opening events are withheld.** If a long local prompt keeps that window open, the client's idle timeout (Claude Code's `CLAUDE_STREAM_IDLE_TIMEOUT_MS`) can fire. Keep `ttft_timeout_s` below it.
|
|
68
|
+
- There is no keep-alive `ping` during that window yet.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# コミット点 — 本文を送り出す前の失敗を、どこまで救えるか
|
|
2
|
+
|
|
3
|
+
> 対象: v2.16.0 以降(`ttft_timeout_s` と `x-should-retry` は v2.17.0)
|
|
4
|
+
> English: [commit-point.en.md](./commit-point.en.md)
|
|
5
|
+
|
|
6
|
+
ストリーミング応答には「ここから先は取り消せない」という点があります。クライアントへ 1 バイトでも本文を送った後に上流が倒れると、別の provider に切り替えることはできません(途中まで届いた文章と、新しい provider の文章がつながらないため)。CodeRouter はこの点を **コミット点** と呼び、その手前で起きた失敗はできるだけ次の provider に回します。
|
|
7
|
+
|
|
8
|
+
## コミット点の手前で起きることと、その扱い
|
|
9
|
+
|
|
10
|
+
| 状況 | 扱い | 必要な設定 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| 上流が接続を拒否・タイムアウト・4xx/5xx(最初のイベントより前) | 次の provider へ。全滅なら **HTTP ステータス付きの Anthropic 形式エラー**(v2.16.0) | なし |
|
|
13
|
+
| 上流がいつまでも最初のトークンを返さない(モデル読み込み中、長いプロンプト処理) | `ttft_timeout_s` を過ぎたら次の provider へ(v2.17.0) | provider に `ttft_timeout_s` |
|
|
14
|
+
| `message_start` などの前置イベントは来たが、本文が来ないまま切れる/空で終わる | 前置イベントをクライアントに見せずに保留し、次の provider へ | profile に `empty_response_action: fallback` |
|
|
15
|
+
| 本文を送り出した**後**に切れる | **切り替えない**。検知して通知する(`stream_truncation_action`、v2.15.0)、または受け取り済みの本文で閉じる(`partial_stitch_action: surface`) | 各設定 |
|
|
16
|
+
|
|
17
|
+
最後の行は CodeRouter でも救えません。2026-10 時点で確認した範囲では、LiteLLM や Bifrost も本文送出後には切り替えません。
|
|
18
|
+
|
|
19
|
+
## `empty_response_action: fallback` が実質的なコミット点
|
|
20
|
+
|
|
21
|
+
この設定にすると、エンジンは `message_start` などの前置イベントを手元に保留し、**最初の本物の出力**(text / tool_use / thinking の delta)が来た時点でまとめて送り出します。そこがコミット点です。それまでに起きた失敗は、クライアントから見えないまま次の provider に回ります。
|
|
22
|
+
|
|
23
|
+
Claude Code で使うなら、まずこれを有効にしてください。
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
profiles:
|
|
27
|
+
- name: claude-code
|
|
28
|
+
providers: [local-llamacpp, openrouter-free]
|
|
29
|
+
empty_response_action: fallback
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## `ttft_timeout_s` — 最初のトークンの期限(v2.17.0)
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
providers:
|
|
36
|
+
- name: local-llamacpp
|
|
37
|
+
kind: anthropic # llama-server の /v1/messages にそのまま流す
|
|
38
|
+
base_url: http://127.0.0.1:8080
|
|
39
|
+
model: ""
|
|
40
|
+
ttft_timeout_s: 20 # 20 秒以内に最初の出力が無ければ次の provider へ
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **数え始め**: その provider への試行を始めた時点。
|
|
44
|
+
- **止まる条件**: 最初の本物の出力が来たとき。前置イベント(`message_start`)だけでは止まりません。ただし、前置イベントが来た後も期限を見続けるには `empty_response_action: fallback` が必要です(それが無いと前置イベントはすぐクライアントに送られ、コミット済みになるため)。
|
|
45
|
+
- **チェーンの最後の provider には適用しません**。次が無いので、待つ方がましだからです。
|
|
46
|
+
- 非ストリーミングの要求には適用しません(そちらは `timeout_s` が上限)。
|
|
47
|
+
- **`/v1/messages`(Anthropic 形式)のストリーミングだけが対象**です。`/v1/chat/completions` には効きません。
|
|
48
|
+
- **`kind: openai_compat` で tools 付きの要求(Claude Code はほぼ毎回 tools を送ります)には効きません。** この組み合わせは、tool call を修復するために上流を非ストリーミングで呼ぶ経路(tool-turn downgrade)を通るので、「最初の出力」が応答全体になってしまうからです。ここに期限をかけると、ゆっくりだが正常なローカルの応答を途中で捨てることになります。ローカルの llama.cpp / Ollama で TTFT カットオフを使うなら、上の例のように `kind: anthropic`(ネイティブの `/v1/messages`)で接続してください。ただしこの接続では、CodeRouter の tool call 修復は働きません(修復は OpenAI 形式から翻訳する経路でだけ動きます)。ネイティブで tool call を正しく出せるモデル向けの選択肢です。
|
|
49
|
+
- 打ち切った試行は通常の失敗として扱われます。理由は `X-CodeRouter-Fallback-Reason: ttft-timeout`、self-healing のガードもこれを学習します。
|
|
50
|
+
|
|
51
|
+
典型的な使い方: ローカルの llama-server がモデルを読み込み中、または長いプロンプトを処理中で、クラウドなら今すぐ答えられる、という場面です。値は「ふだんのローカルの TTFT の 2〜3 倍」程度から始めてください。短すぎると、毎回クラウドに逃げることになります。
|
|
52
|
+
|
|
53
|
+
## 全滅したときに返すもの(v2.16.0 / v2.17.0)
|
|
54
|
+
|
|
55
|
+
チェーン全体が失敗したときは、Anthropic API と同じ形のエラーを返します。
|
|
56
|
+
|
|
57
|
+
| 上流の状況 | HTTP | `error.type` | `x-should-retry` |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| 文脈超過(どの provider でも) | 400 | `invalid_request_error`(`prompt is too long: …`) | `false` |
|
|
60
|
+
| 再試行しても無駄な 400 / 422 | 400 | `invalid_request_error`(上流の文言そのまま) | `false` |
|
|
61
|
+
| 413 | 413 | `request_too_large` | `false` |
|
|
62
|
+
| 429 | 429 | `rate_limit_error`(上流の `retry-after` を転送) | `true` |
|
|
63
|
+
| その他(5xx、タイムアウト、401/403 など) | 502 | `api_error` | 再試行可能な失敗が 1 つでもあれば `true` |
|
|
64
|
+
|
|
65
|
+
ストリーミングの要求でも、最初のイベントより前に全滅した場合はこの HTTP ステータスで返ります(`200` の中に `error` イベントを入れる形ではありません)。Claude Code は `prompt is too long` を見て会話を要約し、`x-should-retry` / `retry-after` を見て再試行するかを決めます。
|
|
66
|
+
|
|
67
|
+
## 既知の制限
|
|
68
|
+
|
|
69
|
+
- **前置イベントを保留している間は、クライアントに何も送れません。** ローカルの長いプロンプト処理でこの時間が長いと、クライアント側の無通信タイムアウト(Claude Code の `CLAUDE_STREAM_IDLE_TIMEOUT_MS`)に当たる可能性があります。`ttft_timeout_s` をその値より短くしてください。
|
|
70
|
+
- 保留中に `ping` を送って接続を保つ機能はまだありません。
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# in plan.md §11.B; once granted, this name will become an alias and
|
|
12
12
|
# `coderouter` will become the canonical distribution name.
|
|
13
13
|
name = "coderouter-cli"
|
|
14
|
-
version = "2.
|
|
14
|
+
version = "2.17.0"
|
|
15
15
|
description = "Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible."
|
|
16
16
|
readme = "README.md"
|
|
17
17
|
requires-python = ">=3.12"
|
|
@@ -131,7 +131,7 @@ def test_429_maps_to_rate_limit_with_retry_after() -> None:
|
|
|
131
131
|
NoProvidersAvailableError("default", [_err(429, '{"error":{"message":"slow down"}}', retry_after="7")])
|
|
132
132
|
)
|
|
133
133
|
assert (mapped.status_code, mapped.error_type) == (429, "rate_limit_error")
|
|
134
|
-
assert mapped.headers == {"retry-after": "7"}
|
|
134
|
+
assert mapped.headers == {"retry-after": "7", "x-should-retry": "true"}
|
|
135
135
|
assert mapped.message == "slow down"
|
|
136
136
|
|
|
137
137
|
|
|
@@ -288,6 +288,7 @@ def test_reject_raised_inside_engine_is_400_not_500(monkeypatch: pytest.MonkeyPa
|
|
|
288
288
|
resp = TestClient(app).post("/v1/messages", json={**_BODY, "stream": stream})
|
|
289
289
|
assert resp.status_code == 400, resp.text
|
|
290
290
|
assert resp.json()["error"]["message"].startswith("prompt is too long")
|
|
291
|
+
assert resp.headers["x-should-retry"] == "false"
|
|
291
292
|
|
|
292
293
|
|
|
293
294
|
@pytest.mark.asyncio
|
|
@@ -350,3 +351,31 @@ async def test_disconnect_watcher_quiet_when_client_stays() -> None:
|
|
|
350
351
|
await asyncio.sleep(0.05)
|
|
351
352
|
assert not watcher.fired
|
|
352
353
|
await watcher.stop()
|
|
354
|
+
# v2.17.0: x-should-retry
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@pytest.mark.parametrize(
|
|
359
|
+
("errors", "expected"),
|
|
360
|
+
[
|
|
361
|
+
([_err(400, _LLAMACPP_OVERFLOW, retryable=False)], "false"),
|
|
362
|
+
([_err(400, _ANTHROPIC_400, retryable=False)], "false"),
|
|
363
|
+
([_err(413, "big", retryable=False)], "false"),
|
|
364
|
+
([_err(429, "slow")], "true"),
|
|
365
|
+
([_err(503, "busy")], "true"),
|
|
366
|
+
([_err(401, "bad key", retryable=False), _err(403, "nope", retryable=False)], "false"),
|
|
367
|
+
([_err(401, "bad key", retryable=False), _err(503, "busy")], "true"),
|
|
368
|
+
([], "false"),
|
|
369
|
+
],
|
|
370
|
+
)
|
|
371
|
+
def test_x_should_retry(errors: list[AdapterError], expected: str) -> None:
|
|
372
|
+
mapped = map_no_providers_error(NoProvidersAvailableError("default", errors))
|
|
373
|
+
assert mapped.headers["x-should-retry"] == expected
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@pytest.mark.parametrize("stream", [False, True])
|
|
377
|
+
def test_x_should_retry_reaches_client(make_client, stream: bool) -> None: # type: ignore[no-untyped-def]
|
|
378
|
+
client = make_client([_err(401, "bad key", retryable=False)])
|
|
379
|
+
resp = client.post("/v1/messages", json={**_BODY, "stream": stream})
|
|
380
|
+
assert resp.status_code == 502
|
|
381
|
+
assert resp.headers["x-should-retry"] == "false"
|